本文深入剖析"鸢语·风筝扎制与放飞俱乐部"应用的完整技术架构,从ColorPalette色彩系统设计、@Observed装饰器驱动的细粒度状态管理、@Builder装饰器的组件化复用,到Stack/Column/Row三层布局体系与ArkUI渲染管线的协同运作,全方位展现HarmonyOS ArkTS API 24在文创类应用中的工程化落地范式。


一、开篇:声明式UI开发范式与HarmonyOS技术体系

在这里插入图片描述

1.1 声明式UI开发范式的核心思想

声明式UI(Declarative UI)是现代前端与移动端开发的主流范式,其核心思想在于开发者描述"界面应该是什么样子",而非命令式地一步步告诉框架"如何构建界面"。在HarmonyOS的ArkUI框架中,这种范式被推向了极致——开发者通过结构化的组件描述语言,以树形结构逐层声明界面的组成、样式与行为,框架则负责底层的差异计算(diffing)、节点复用与渲染调度。

鸢语应用正是这一范式的典型产物。整个界面由一个根组件PageKiteCrafting构成,其内部通过@Component装饰器标记为组件结构体,通过@Entry装饰器标记为页面入口。在build()方法中,开发者以声明式的语法描述了从最底层背景色、到特效层、再到主内容区与弹框层的完整堆叠关系。框架在运行时会自动维护这棵组件树,当状态变量发生变化时,仅对受影响的子树进行最小粒度的重新渲染,从而保证性能与流畅度。

1.2 HarmonyOS ArkTS与API 24的技术底座

在这里插入图片描述

ArkTS是HarmonyOS生态中基于TypeScript扩展的应用开发语言,它在保留TS类型系统与语法特性的基础上,引入了一系列UI开发专用的装饰器(Decorator)与状态管理机制。在HarmonyOS API 24(对应HarmonyOS 6.1.1版本)中,这些能力得到了进一步的强化与优化。

具体而言,API 24在以下几个方面为鸢语应用提供了技术底座:一是@Observed装饰器的精细化观察能力,使得自定义类的属性变化能够被UI层精准捕获;二是@Builder装饰器的复用机制,允许将可复用的UI片段抽取为命名构建函数,减少代码冗余;三是ForEach组件的高性能列表渲染,配合键值生成函数实现节点的精准复用;四是hitTestBehavior属性对触摸事件穿透的精细控制,为特效层的无干扰叠加提供了原生支持。

1.3 @Observed状态管理的深度机制

状态管理是声明式UI的灵魂。在鸢语应用中,状态管理体系分为三个层次:第一层是@State装饰器标记的组件内部状态,如mainTabsubTabtickaddOpen等,它们驱动着界面的切换与动画的播放;第二层是@Observed装饰器标记的自定义数据类,如PostItemKiteItemCraftItemGearItem,它们的属性变化能够被UI层观察到;第三层是顶层的静态常量数据,如POST_LISTKITE_LIST等,作为应用的初始数据源。

这种分层状态管理设计体现了ArkUI的核心理念:状态与视图解耦,数据驱动渲染。当@State装饰的变量发生赋值变化时,框架会自动标记该变量所属的组件为"脏",并在下一帧重新执行build()方法中依赖该变量的部分。而@Observed则更进一步,它使得自定义类的实例属性也具备了可观察能力——当数组中某个CraftItemstatus字段被修改时,对应的UI卡片会自动更新进度条颜色与状态标签,无需手动触发刷新。

1.4 @Builder装饰器与组件化复用哲学

在这里插入图片描述

@Builder装饰器是ArkUI中实现UI复用的核心机制。与传统的组件拆分不同,@Builder提供了一种更轻量的复用方式——它将一段UI描述封装为一个可调用的函数,可以在同一个组件内的多个位置被调用,也可以通过参数传递实现更灵活的配置。

鸢语应用大量运用了@Builder装饰器来组织代码结构。从fxLayer()特效层、header()头部区域、subNav()子导航栏,到pageFeatured()精选页、pageMarket()市集页、pageCrafts()扎制页等七个子页面,再到modalOverlay()遮罩层与四个弹框体构建函数,整个应用的UI被拆解为二十余个@Builder函数。这种拆解方式既保证了代码的可读性与可维护性,又避免了过度组件化带来的性能开销——因为@Builder函数本质上是内联的,不会产生额外的组件实例开销。

1.5 Stack/Column/Row布局系统的三维协同

ArkUI的布局系统以三大基础容器为核心:Stack堆叠布局、Column垂直布局、Row水平布局。这三种容器的组合几乎可以构建任何复杂的界面结构,鸢语应用便是这一理念的绝佳例证。

在垂直方向上,Column承担了主要的内容流组织职责——头部区域的信息堆叠、卡片列表的纵向排列、弹框内容的上下布局,均由Column完成。在水平方向上,Row负责同一行内多元素的对齐与分布——底部导航栏的四等分排列、数据四格的横向平铺、卡片内部的左右结构,都依赖RowlayoutWeight属性实现弹性分配。而在深度方向上,Stack则构建了界面的层次感——背景色在最底层,特效层叠加其上,主内容区再覆盖特效层,弹框则在最顶层悬浮。这种三维布局体系在ArkUI的渲染管线中被高效地转换为渲染指令,最终呈现出层次分明、交互流畅的界面效果。

1.6 ArkUI渲染管线与帧调度机制

在这里插入图片描述

ArkUI的渲染管线是一套精心设计的异步渲染系统,其大致可以分为四个阶段:布局计算(Layout)、绘制(Draw)、渲染(Render)与合成(Composite)。在鸢语应用中,这套管线的运作体现在多个细节之中。

首先,布局计算阶段采用了"从父到子、再从子到父"的两趟遍历机制。父容器将约束条件传递给子组件,子组件在约束范围内计算自身尺寸后再将结果返回给父容器。鸢语应用中大量使用的layoutWeight(1)属性正是这一机制的体现——它告诉父容器该子组件需要占据剩余的全部空间,父容器在汇总所有子组件的固定尺寸后,将剩余空间平均分配给设置了layoutWeight的子组件。

其次,绘制与渲染阶段由框架自动调度,开发者无需关心具体的绘制指令。鸢语应用中的圆角(borderRadius)、背景色(backgroundColor)、透明度(opacity)、变换(rotatetranslate)等属性,都会被框架转换为对应的绘制指令,交由GPU进行硬件加速渲染。特别值得一提的是,rotatetranslate等变换属性会触发合成层优化——它们不会重新触发布局与绘制,而是直接在合成阶段由GPU完成变换,这也是为什么风筝摇摆特效能够在低性能损耗下流畅运行的关键原因。

1.7 天蓝色彩鸢色彩设计系统的构建逻辑

鸢语应用的色彩系统以天蓝色(#4A6FA5)为主色调,橙红色(#D96C47)为强调色,丝绸棕(#B08968)为特色点缀色,构建了一套层次分明、语义清晰的色彩体系。这套色彩系统并非随意选取,而是有着深刻的设计考量。

天蓝色(Sky Blue)作为主色,呼应了风筝飞翔的天空意象,传递出开阔、自由、宁静的情感。它有三个层级:primary主色用于主要操作按钮与重要文字、primaryLight浅色系用于选中态背景与高亮区域、primaryDark深色系用于需要更强对比度的文字与图标。橙红色(Orange-Red)作为强调色,同样分为accent主强调色与accentLight浅强调色两个层级,用于需要吸引用户注意力的操作入口与状态标签,比如"预约场地"按钮、"打卡"按钮、热门标签等。

丝绸棕(Silk Brown, #B08968)则是这套色彩系统中的点睛之笔。它源于传统风筝制作中使用的丝绸面料的颜色,既呼应了"扎制"这一核心工艺,又为整体偏冷的色调增添了一抹温暖的手工质感。在界面中,丝绸色被用于"适宜指数"标签、"预约场地"按钮的背景色等位置,起到了调和冷暖色调、强化品牌调性的作用。

1.8 风筝放飞俱乐部的交互模式设计

鸢语应用的交互模式围绕"风筝扎制与放飞俱乐部"这一场景展开,设计了四大核心功能模块:首页精选、纸鸢市集、风筝课堂、个人中心。每个模块下又细分了多个子功能,形成了"底部4Tab + 首页7内容Tab"的双层导航结构。

这种交互模式的设计逻辑在于:底部Tab提供大的功能分区(社区/市集/学习/个人),首页的子Tab则提供更细粒度的内容切换(精选/橱窗/扎制/工坊/课堂/圈子/器材)。用户进入应用后,首先看到的是精选内容聚合页,涵盖了玩法入口、数据统计、人气纸鸢、鸢友动态等多个信息维度,帮助用户快速了解俱乐部的最新动态。而当用户有明确的使用目标时,则可以通过子Tab直接跳转到对应的功能页面,提高操作效率。

此外,应用还设计了丰富的弹框交互模式,包括发布鸢语(新增)、编辑扎制单(编辑)、取消订单/退课(删除)、预约放飞场地(预约)四种核心操作弹框,覆盖了CRUD全场景,形成了完整的交互闭环。


二、ColorPalette接口设计与天色彩鸢色彩系统深度解析

2.1 ColorPalette接口的结构设计

在这里插入图片描述

在鸢语应用的色彩体系中,ColorPalette接口是整个设计系统的基石。它以TypeScript接口的形式定义了16个颜色字段,涵盖了主色体系、辅助色体系、文本色体系、背景色体系、边框色体系、功能色体系以及特色色体系等多个维度。这种以接口先行的设计方式,确保了色彩使用的规范性与一致性——任何组件在引用颜色时,都必须通过COLORS常量对象访问预定义的颜色字段,而非直接使用十六进制色值。

接口的定义非常简洁明了,每个字段都有明确的语义命名。开发者在阅读代码时,只需看到字段名就能理解该颜色的用途,无需反复对照设计稿。例如,textPrimary表示主要文本颜色,textSecondary表示次要文本颜色,textHint表示提示文本颜色,三者构成了完整的文本色彩层级。同样,successwarningdanger三种功能色则分别对应成功、警告、危险三种语义状态,在表单验证、状态提示等场景中被广泛使用。

2.2 主色体系:天蓝色的三重奏

在这里插入图片描述

主色体系由三个字段构成:primaryprimaryLightprimaryDark。这三重天蓝色调构成了应用视觉的核心骨架。

primary字段的色值为#4A6FA5,这是一种中度饱和的天蓝色。它既不过于鲜艳刺眼,也不过于暗淡无光,恰到好处地平衡了视觉吸引力与长时间阅读的舒适度。在界面中,主色被用于最重要的操作按钮(如"立即定制"、“保存”、“发布”)、主要文本的选中态、底部导航的激活图标等位置,是用户视觉注意力的核心锚点。

primaryLight字段的色值为#E4EAF4,这是一种非常浅的天蓝色。它的主要用途是作为选中态的背景色——在子导航栏(subNav)中,当前选中的Tab背景色就是primaryLight,与未选中的米色背景(COLORS.bg)形成柔和的对比。这种浅色背景的运用,既突出了选中状态,又不会过于突兀,保持了界面的整体协调性。

primaryDark字段的色值为#33517E,这是一种较深的天蓝色。它被用于需要更高对比度的文字场景,比如橙色卡片上的深色文字标签(“适宜指数”)。在橙红色(COLORS.accent)的背景上,深色天蓝色能够提供足够的对比度,确保文字的可读性,同时与整体的蓝色主题保持呼应。

2.3 强调色体系:橙红色的视觉焦点

强调色体系由accentaccentLight两个字段组成。橙红色(#D96C47)作为强调色,与天蓝色主色形成了冷暖对比,为界面注入了活力与温度。

accent字段的色值为#D96C47,是一种温暖而鲜明的橙红色。它在界面中的使用策略非常精准——仅用于需要强烈吸引用户注意力的位置。例如,头部区域的"放飞窗口卡"整个背景都采用了橙红色,配合白色文字,形成了强烈的视觉冲击力,确保用户一眼就能看到今日的放飞信息。此外,"打卡"按钮、"预约场地"按钮、热门标签、关注进度条等位置也使用了橙红色,这些位置要么是核心操作入口,要么是需要突出显示的状态信息。

accentLight字段的色值为#F9E6DE,是一种非常浅的橙粉色。它的用途与primaryLight类似,主要作为状态标签的背景色使用。在鸢友动态、纸鸢市集、我的扎制等页面中,各种状态标签(如"收藏级"、“进阶”、“已交付”、“扎制中”)的背景色都采用了浅橙红色。这种浅色背景既能够突出标签内容,又不会抢夺主要内容的视觉注意力,是一种非常精妙的设计手法。

2.4 背景与边框:构建层次的基底色

背景色体系由bgcardBg两个字段构成。bg的色值为#F4F1EA,是一种略带暖调的米灰色,作为整个应用的页面背景色。这种暖灰色调既不会像纯白色那样刺眼,又能为内容卡片提供足够的对比度。更重要的是,它与风筝制作的"纸"、"绢"等材质的颜色相呼应,为整个应用奠定了温润、手工的基调。

cardBg的色值为#FFFFFF,即纯白色。所有的内容卡片、数据四格、弹框等容器的背景都使用纯白色,与米灰色的页面背景形成柔和的层次区分。这种"米灰底 + 白卡"的组合是现代UI设计中非常经典的配色方案,既保证了内容区域的清晰度,又营造了干净、整洁的视觉感受。

边框色border的色值为#E8E4DB,是一种非常浅的暖灰色。它被用于分割线、进度条背景、输入框边框等位置。边框色与背景色(#F4F1EA)的对比度非常低,这意味着它主要起到的是"暗示"而非"强调"的作用——用户能够感知到边界的存在,但不会被边界分散注意力。这种设计理念体现了"内容优先"的原则,所有的装饰性元素都应该退居幕后,让内容本身成为视觉的主角。

2.5 文本色体系:三级递进的信息层级

文本色体系由textPrimarytextSecondarytextHint三个字段构成,形成了三级递进的信息层级。

textPrimary的色值为#3A3A45,是一种略带蓝调的深灰色。它被用于最重要的文本内容,如标题、名称、主要数据等。与纯黑色(#000000)相比,深灰色的文字在浅色背景上显得更加柔和,长时间阅读也不容易产生视觉疲劳。同时,略带蓝调的色相与天蓝色主色形成了微妙的呼应,使得整体色调更加统一。

textSecondary的色值为#6E6E7A,是一种中度灰色。它被用于次要的文本内容,如描述文字、副标题、辅助信息等。与主要文本相比,次要文本的对比度降低了约50%,这样既保证了可读性,又清晰地区分了信息层级——用户的视线会自然地先被主要文本吸引,然后再浏览次要文本。

textHint的色值为#ADADB8,是一种浅灰色。它被用于提示性文字,如时间戳、占位符、标签说明等。提示文字的对比度最低,仅在用户需要时才会被注意到,不会干扰主要内容的阅读。这种三级文本色体系的设计,使得信息的呈现具有明确的优先级,用户能够快速扫描并获取关键信息。

2.6 功能色体系:语义化的状态表达

功能色体系由successwarningdanger三个字段构成,分别对应成功、警告与危险三种语义状态。

success的色值为#7BA05B,是一种柔和的草绿色。它被用于表达积极、正向的状态,如"已交付"的扎制订单、成功的操作反馈、放飞时长数据等。绿色在色彩心理学中通常与成长、健康、成功等概念相关联,使用绿色作为成功色能够在用户潜意识中建立积极的情感联结。

warning的色值为#D9973B,是一种金橙色。它被用于表达需要用户注意的警告状态,如"绑扎中"的工序状态、鸢币数据等。金色既具有橙色的活力,又带有一丝警示的意味,非常适合用于需要引起注意但又不是紧急情况的场景。

danger的色值为#C0503F,是一种深红色。它被用于表达危险、不可逆的操作,如"取消扎制订单"按钮、“退课"按钮、删除确认弹框的确认按钮等。红色作为危险色具有跨文化的普适性,用户几乎本能地就能理解红色按钮意味着"需要谨慎操作”。在鸢语应用中,危险操作按钮都被设计为红色背景配白色文字,并且在点击前会弹出确认弹框,形成了双重安全保障。

2.7 丝绸色:传统工艺的数字化转译

在16个颜色字段中,silk(丝绸色)是最具特色的一个。它的色值为#B08968,是一种温润的棕褐色,灵感来源于传统风筝制作中使用的丝绸面料的颜色。

丝绸色在界面中的应用虽然不多,但每一处都恰到好处。在放飞窗口卡中,丝绸色被用作"适宜指数"标签的背景色,与橙红色的卡片背景形成和谐的暖色调搭配,同时又为卡片增添了一抹手工质感。在同一个卡片中,"预约场地"按钮的背景色也采用了丝绸色,搭配深天蓝色(primaryDark)的文字,呈现出一种复古而精致的视觉效果。

丝绸色的引入,使得整个色彩系统不再是冷冰冰的数字化配色,而是充满了人文温度与工艺情怀。它让用户在使用应用的过程中,能够潜移默化地感受到传统风筝制作的工艺之美,这正是鸢语应用品牌调性的核心所在。

2.8 白色的多重身份

ColorPalette接口中,white字段(色值#FFFFFF)虽然只有一个定义,但在实际使用中却扮演着多重角色。它既是cardBg卡片背景色的取值,也是各种深色背景上的文字颜色(如橙红色卡片上的白色文字、天蓝色按钮上的白色文字),同时还是弹框的背景色。

这种"一符多义"的设计在色彩系统中是非常常见的。白色作为最高亮度的颜色,具有最强的对比度,因此在深色背景上用作文字色时能够提供最佳的可读性。而作为背景色时,白色又能够为内容提供最干净、最纯粹的展示舞台。鸢语应用对白的运用可谓恰到好处,既不过度使用导致界面过于刺眼,也不会因使用不足而显得压抑。


三、@Observed数据模型:四类核心实体的逐字段剖析

在这里插入图片描述

3.1 @Observed装饰器的工作原理

在深入分析各个数据模型之前,有必要先理解@Observed装饰器的工作原理。在ArkUI框架中,@Observed用于装饰自定义类,使得该类的实例属性具备可观察性。当被@Observed装饰的类的实例属性发生变化时,框架能够自动检测到变化并触发相关UI的更新。

需要注意的是,@Observed@State是配合使用的。@State装饰的变量负责监听整个对象或数组的引用变化,而@Observed则负责监听对象内部属性的变化。在鸢语应用中,postListcraftList都被@State装饰,而它们的元素类型PostItemCraftItem则被@Observed装饰。这样的组合使得无论是数组本身的变化(如新增、删除元素),还是数组元素内部属性的变化(如修改某条扎制订单的状态),都能够被UI层准确地响应。

从实现机制上来看,@Observed装饰器会在编译阶段为被装饰的类添加一系列的getter/setter代理。当属性被赋值时,setter会通知框架该属性发生了变化,框架进而查找依赖该属性的UI组件并标记为需要重新渲染。这种基于属性劫持的观察机制,相比传统的脏检查方式,具有更高的性能与更精准的更新粒度。

3.2 PostItem:鸢友动态数据模型

在这里插入图片描述

PostItem是鸢友社区中动态内容的数据模型,它描述了一条社区动态的完整信息结构。该模型包含6个字段,分别是idnickavatartexttimelikes

id字段的类型为number,是每条动态的唯一标识符。在ForEach组件中,id被用作键值生成函数的返回值(it.id.toString()),这确保了列表渲染时节点的正确复用。当列表数据发生变化时,框架通过对比键值来判断哪些节点可以复用、哪些需要新建、哪些需要删除,从而实现高效的列表更新。

nick字段的类型为string,存储发布者的昵称。在鸢语应用中,昵称不仅是身份标识,更是社区氛围的重要组成部分——从"追风老周"到"竹条手作人",从"夜放鸢者"到"绢面绘制师",每一个昵称都暗示了用户在风筝文化中的独特身份与专长,构建了一个充满人情味的社区画像。

avatar字段的类型为string,但存储的并非图片URL,而是emoji字符。这种使用emoji作为头像的设计非常巧妙——它既免去了图片资源的加载开销,又能够直观地表达用户的个性特征。每个emoji头像都与用户的昵称形成呼应:"追风老周"的头像是🪁(风筝),"竹条手作人"的头像是✂️(剪刀),"夜放鸢者"的头像是🌙(月亮),"绢面绘制师"的头像是🖌️(画笔)。这种统一的设计语言使得整个社区的视觉呈现既简洁又富有个性。

text字段的类型为string,存储动态的正文内容。每条动态的文字都控制在一句话的长度,内容围绕风筝扎制与放飞的主题展开,既有经验分享,也有活动通知,还有心情记录。这种短文本形式非常适合移动端的阅读场景,用户可以快速浏览并获取信息。

time字段的类型为string,存储发布时间的相对描述。它使用的是"4分钟前"、“22分钟前”、“1小时前”、"昨天"等相对时间格式,而非绝对时间戳。这种相对时间的呈现方式更加人性化,用户能够直观地感受到动态的新鲜程度。

likes字段的类型为number,存储点赞数。点赞数是社区内容热度的直观体现,也是用户参与感的重要来源。在UI上,点赞数与风筝emoji(🪁)结合展示,既符合应用的主题调性,又替代了传统的"点赞"图标,形成了独特的品牌识别。

3.3 KiteItem:纸鸢市集数据模型

KiteItem是纸鸢市集中风筝商品的数据模型,它描述了一款风筝产品的完整信息。该模型包含6个字段:idnamecraftpricelevelhot

id字段同样是唯一标识符,用于列表渲染的键值生成。与PostItem不同的是,KiteItemid在纸鸢橱窗的双列布局中还被用于索引定位——通过idx % 2 === 0的判断,将风筝商品两两排列为一行,形成瀑布流式的双列布局效果。

name字段存储风筝的名称。风筝名称的命名非常讲究,通常采用"品类 · 特色"的格式,如"沙燕 · 黑锅底"、“蜈蚣串 · 十五节”、“蝴蝶 · 软翅对彩”、"鹰 · 远看乱真"等。这种命名方式既体现了传统风筝的分类体系(硬翅类、软翅类、串式类、板子类、立体类),又突出了每款风筝的独特之处,充满了文化韵味。

craft字段存储风筝的工艺描述。它简要说明了风筝的制作工艺与材质特点,如"传统硬翅 · 手绘"、“龙头串式 · 精扎”、“绢面 · 对染”、"板子式 · 竹骨"等。工艺描述帮助用户快速了解风筝的制作方式与品质特点,是购买决策的重要参考信息。

price字段存储风筝的价格,类型为string而非number。这是因为价格不仅包含数字,还包含货币符号与单位,如"¥268/只"、“¥880/串”、"¥48/只"等。使用字符串类型存储价格可以灵活地表达各种价格格式,避免了数字类型在格式化显示时的额外处理。

level字段存储风筝的难度等级。等级分为"入门"、“进阶”、“高手”、"收藏级"四个层级,分别对应不同的用户群体与价格区间。入门级(如八卦平面板子,¥48/只)价格低廉、耐摔易飞,适合初学者;进阶级(如蝴蝶软翅对彩,¥158/只)工艺较好、飞行稳定,适合有一定经验的爱好者;高手级(如夜光龙带灯,¥668/串)技术含量高、操作难度大,适合资深玩家;收藏级(如沙燕黑锅底,¥268/只;瘦燕三蓝,¥388/只)工艺精湛、具有收藏价值,面向高端用户与收藏家。

hot字段的类型为number,存储风筝的关注度(热度值)。热度值不仅是风筝受欢迎程度的体现,还被用作进度条的百分比数值来可视化展示。在纸鸢橱窗的列表页中,每款风筝都有一条热度进度条,进度条的宽度与颜色都由hot值决定——热度越高,进度条越长,颜色也会根据风筝类型的不同而呈现出差异化的视觉效果。

3.4 CraftItem:扎制订单数据模型

CraftItem是扎制工坊中订单的数据模型,它描述了一笔扎制订单的完整状态。该模型包含5个字段:idnameitemstatusprogress

id字段为订单的唯一标识符,用于列表渲染与订单定位。在编辑扎制单的功能中,id配合索引editIdx来定位需要编辑的订单对象,实现了数据与UI的精准对应。

name字段存储扎制订单的名称,即所扎制风筝的型号与款式,如"沙燕 · 胖燕"、“蜈蚣串 · 二十节”、"瘦燕 · 赛事版"等。订单名称与市集风筝名称形成了对应关系,用户可以清晰地了解自己正在扎制的是哪一款风筝。

item字段存储扎制内容的详细描述,它说明了该订单的具体工艺要求与规格参数。例如,"三停十二节 · 手绘脸"描述了沙燕风筝的结构特点与绘制方式,"龙头骨架 · 绢面"说明了蜈蚣串的主要部件与材质,"复刻 1932 原款"则表明了瘦燕赛事版的历史渊源。这些详细描述使得每一笔扎制订单都具有独特的工艺价值。

status字段存储订单的当前状态,类型为string。状态值包括"已交付"、“扎制中”、“裱糊中”、"绑扎中"与"设计稿"等多个层级,对应了风筝扎制的完整工艺流程。从设计稿到绑扎中,再到裱糊中、扎制中,最后到已交付,每一个状态都代表了工艺推进的不同阶段。在UI上,不同的状态通过不同的颜色来区分——已交付为绿色、扎制中为天蓝色、裱糊中为橙红色、绑扎中为金橙色、设计稿为灰色,形成了清晰的状态色标系统。

progress字段的类型为number,存储扎制进度的百分比数值。进度值与状态字段是相互关联的——不同的状态对应不同的进度范围。例如,"已交付"对应100%的进度,"扎制中"对应约72%的进度,"裱糊中"对应约55%的进度,"绑扎中"对应约38%的进度,"设计稿"则对应约10%的进度。进度值通过进度条的形式可视化展示,让用户能够直观地了解订单的完成程度。

3.5 GearItem:器材配件数据模型

GearItem是器材库中配件商品的数据模型,它描述了一种风筝制作或放飞器材的基本信息。该模型包含4个字段:idnamedescicon

id字段为器材的唯一标识符,用于列表渲染的键值生成。与其他数据模型类似,GearItemid也被用于双列布局中的索引判断,实现器材商品的双列展示。

name字段存储器材的名称。器材名称采用"品类 · 规格/材质"的命名格式,如"竹条 · 三年毛竹"、“绢面 · 熟绢”、“线轮 · 金属轴承”、"乳胶 · 木工级"等。这种命名方式清晰地说明了器材的种类与品质特点,帮助用户快速判断器材是否符合自己的需求。

desc字段存储器材的简短描述,通常是四个字的核心卖点。例如,竹条的描述是"顺纹易弯",突出了三年毛竹材质的优良弯曲性能;绢面的描述是"透光好上色",强调了熟绢材质在绘制效果上的优势;线轮的描述是"放线顺滑",说明了金属轴承带来的流畅操作体验。这些精炼的描述词语能够在有限的空间内传递最关键的产品信息。

icon字段存储器材的图标,与PostItemavatar字段类似,使用emoji字符来表示。每种器材都有对应的emoji图标:竹条是🎋、绢面是🧵、线轮是🎣、乳胶是🧴、风筝线是🪢、烤竹蜡烛是🕯️、矿物颜料是🎨、放飞绞盘是⚙️。emoji图标的使用既统一了视觉风格,又为器材列表增添了生动的视觉元素,使得单调的器材展示变得富有趣味。

3.6 辅助接口:图表与课程的数据结构化

除了四个主要的@Observed数据模型外,鸢语应用还定义了四个辅助接口,用于描述图表数据与课程数据的结构。它们分别是WeekChartItemKiteChartItemCourseItemPlayTypeItem

WeekChartItem接口用于描述周放飞次数柱状图的数据项,包含label(星期标签)与value(放飞次数)两个字段。该接口的数据被用于WEEK_CHART数组,驱动"本周放飞打卡次数"柱状图的渲染。每个数据项对应一周中的某一天,通过柱状图的高度直观地展示每天的放飞频次。

KiteChartItem接口在WeekChartItem的基础上增加了color字段,用于描述收藏类别构成图的数据项。color字段指定了每个类别的进度条颜色,使得不同类别的数据在视觉上能够被快速区分。硬翅类为天蓝色、软翅类为橙红色、板子类为草绿色、串式类为金橙色、立体类为丝绸棕,五种颜色与五类风筝一一对应,形成了完整的视觉编码体系。

CourseItem接口用于描述风筝课堂的课程数据,包含title(课程标题)、week(周次)、progress(学习进度)与color(进度条颜色)四个字段。每门课程都有独特的进度条颜色,与课程的学习内容形成象征性的关联——竹材挑选与烤弯为天蓝色(对应天空与竹材)、绑扎结构与三停法为橙红色(对应绑扎的力度与工艺)、绢面裱糊与收边为草绿色(对应绢面的温润质感)、传统纹样与配色为金橙色(对应颜料的色彩)、风力判断与放飞为丝绸棕(对应自然与户外)。

PlayTypeItem接口用于描述玩法类型的数据,包含name(玩法名称)、desc(玩法描述)、icon(玩法图标)与hot(是否热门)四个字段。该接口的数据被用于首页精选的"在鸢语能玩什么"四格入口,每个入口对应一种核心玩法。其中hot字段为布尔类型,用于标记热门玩法——"扎制工坊"与"放飞活动"被标记为热门,在UI上会显示红色的"热门"标签,引导用户优先关注。


四、静态数据数组:应用内容生态的全景展现

4.1 POST_LIST:鸢友社区的内容生态

POST_LIST数组是鸢友社区的初始数据源,包含8条精心设计的鸢友动态。这些动态并非随机生成,而是构建了一个完整的社区生态画像——从不同身份的用户、不同类型的内容,到不同的发布时间与互动热度,每一条数据都在为社区氛围服务。

从用户身份来看,8条动态的发布者涵盖了多种角色:"追风老周"代表了资深放飞爱好者,"竹条手作人"代表了工艺匠人,"夜放鸢者"代表了特色玩法玩家,"鸢语主理人"代表了官方运营角色,"绢面绘制师"代表了美术工艺师,"滨江常客"代表了日常放飞用户,"串式爱好者"代表了专项爱好者,"童心未泯"代表了亲子用户群体。这种多元化的用户画像使得社区内容丰富多样,能够满足不同用户的兴趣需求。

从内容类型来看,动态内容涵盖了多个维度:有放飞体验分享(“三米沙燕上了天,线轮放出去两百米”),有工艺经验传授(“烤竹要顺纹慢弯,急了必裂,切记”),有特色玩法展示(“带灯串式夜放,回头率百分之百”),有活动通知(“沙燕复刻赛还有6个名额,本周截止”),有技术探讨(“矿物色画脸谱,飞起来颜色稳”),有安全提醒(“阵风五级别上硬翅,吃过大亏”),有组队需求(“十五节蜈蚣串组装完成,求场地”),有情感记录(“带孩子扎了只小板子,他笑一下午”)。这种内容的多样性确保了社区的活跃度与吸引力。

从时间线与热度分布来看,发布时间从"4分钟前"到"昨天",呈现出自然的时间梯度;点赞数从128到52,形成了合理的热度衰减曲线。这种设计使得社区看起来真实可信,而非人为堆砌的数据。

4.2 KITE_LIST:纸鸢市集的商品矩阵

KITE_LIST数组包含8款风筝产品,构成了纸鸢市集的核心商品矩阵。这8款风筝覆盖了传统风筝的五大分类,形成了从入门到收藏级的完整价格梯度。

从品类分布来看,硬翅类有两款(沙燕·黑锅底、瘦燕·三蓝),串式类有两款(蜈蚣串·十五节、夜光龙·带灯),软翅类有一款(蝴蝶·软翅对彩),板子类有两款(鹰·远看乱真、八卦·平面板子),立体类有一款(金鱼·双尾摆)。这种品类分布基本反映了传统风筝的市场格局——硬翅类与串式类作为代表性品类占据了半壁江山,其他品类作为补充满足不同用户的偏好。

从价格梯度来看,8款风筝的价格从¥48到¥880不等,覆盖了从入门到高端的完整区间。¥48的八卦平面板子定位入门练习,¥128的金鱼双尾摆定位入门进阶,¥158的蝴蝶软翅对彩与¥198的鹰定位进阶级别,¥268的沙燕黑锅底与¥388的瘦燕三蓝定位收藏级别,¥668的夜光龙与¥880的蜈蚣串则定位高手与专业级别。这种梯度化的定价策略能够满足不同消费能力用户的需求,同时也为用户提供了升级的空间。

从热度值来看,热度从95到60不等,与价格和难度形成了一定的相关性——经典的沙燕黑锅底热度最高(95),传统的蜈蚣串次之(91),而较为小众的夜光龙热度最低(60)。这种热度分布符合大众消费的规律,也为用户的购买决策提供了参考依据。

4.3 CRAFT_LIST:扎制工坊的订单全景

CRAFT_LIST数组包含8笔扎制订单,展示了扎制工坊的业务全景。这些订单覆盖了不同的风筝类型、工艺阶段与进度状态,呈现出工坊繁忙而有序的工作景象。

从订单类型来看,既有传统经典款式(沙燕·胖燕、瘦燕·赛事版),也有大型串式(蜈蚣串·二十节、夜光小串·八节),还有装饰摆件(鹰·悬挂件)、婚庆定制(婚庆双燕·对)、教学套件(社团教学套件·20套)以及展厅特装(展厅主视觉·巨型)。这种多元化的订单类型反映了扎制工坊业务的广度——从个人定制到批量生产,从日常用品到商业展示,几乎涵盖了风筝制作的所有应用场景。

从工艺阶段来看,8笔订单分布在不同的进度状态:已交付1笔(沙燕·胖燕)、扎制中2笔(蜈蚣串·二十节、社团教学套件)、裱糊中1笔(瘦燕·赛事版)、绑扎中2笔(鹰·悬挂件、婚庆双燕·对)、设计稿2笔(夜光小串·八节、展厅主视觉·巨型)。这种均匀的状态分布展示了工坊生产线的完整流程,用户可以直观地看到从设计到交付的每一个环节。

从进度数值来看,进度从100%到8%不等,与状态形成了精确的对应关系。已交付的订单进度为100%,扎制中的订单进度为74%与22%(因复杂度不同而有差异),裱糊中的订单进度为56%,绑扎中的订单进度为42%与30%,设计稿阶段的订单进度为16%与8%。这种精细化的进度管理体现了扎制工坊的专业性。

4.4 GEAR_LIST:器材库的物资体系

GEAR_LIST数组包含8种器材配件,构成了风筝制作与放飞的完整物资体系。这些器材按照工艺流程的顺序排列,从原材料到工具,再到耗材与配件,形成了一条完整的供应链。

从分类来看,8种器材可以分为三大类:扎制材料类(竹条、绢面、矿物颜料)、绑扎耗材类(乳胶、风筝线)、放飞工具类(线轮、烤竹蜡烛、放飞绞盘)。这种分类方式与器材库页面的三大分区(扎制区、绑扎区、放飞区)形成了对应关系,用户在浏览时能够快速定位到所需的器材类别。

从描述来看,每种器材都有精准的四字卖点描述,突出了产品的核心优势。"顺纹易弯"强调竹条的材质特性,"透光好上色"突出绢面的绘画性能,"放线顺滑"说明线轮的操作体验,"高强度不打卷"展示风筝线的性能优势。这些精炼的描述词语能够在用户浏览时快速传递产品价值,提升购买转化率。

4.5 WEEK_CHART与KITE_CHART:数据可视化的基础

WEEK_CHARTKITE_CHART两个数组是应用中数据可视化功能的数据基础,分别驱动柱状图与条形图的渲染。

WEEK_CHART数组包含7个数据项,对应周一到周日的放飞打卡次数。数据呈现出明显的工作日低、周末高的规律:周一到周五的放飞次数在2到9次之间波动,而周六达到16次、周日达到13次。这种数据分布真实地反映了风筝爱好者的活动规律——工作日时间有限,只能在下班后或午休时间短暂放飞;而周末则有充足的时间进行长时间的放飞活动。数据中还包含了一个"周末放飞占比63%"的统计结论,进一步量化了这一规律。

KITE_CHART数组包含5个数据项,对应五大风筝类别的收藏占比。硬翅类占比最高(32%),软翅类次之(24%),板子类第三(18%),串式类第四(14%),立体类最少(12%)。这种分布反映了传统风筝的市场格局——硬翅类(尤其是沙燕)作为最具代表性的传统风筝,收藏者最多;而立体类由于制作难度大、飞行要求高,受众相对较小。每个类别都有对应的颜色编码,与整体色彩系统保持一致。

4.6 TYPE_LIST与COURSE_LIST:玩法与学习的内容体系

TYPE_LISTCOURSE_LIST两个数组分别承载了玩法入口与课程内容的数据。

TYPE_LIST数组包含4种玩法类型:扎制工坊、放飞活动、纸鸢市集、复刻课堂。这四种玩法构成了鸢语应用的核心功能矩阵,分别对应用户的四类核心需求:动手制作(扎制工坊)、户外放飞(放飞活动)、购买收藏(纸鸢市集)、学习进阶(复刻课堂)。其中扎制工坊与放飞活动被标记为热门,这与应用的核心定位——风筝扎制与放飞俱乐部——相一致,也引导用户优先关注核心功能。

COURSE_LIST数组包含5门课程,构成了风筝课堂的完整学习路径。课程按照学习的先后顺序排列:从竹材挑选与烤弯(第1周),到绑扎结构与三停法(第2周),再到绢面裱糊与收边(第3周)、传统纹样与配色(第4周),最后是风力判断与放飞(第5周)。这条学习路径遵循了风筝制作的实际工艺流程——从材料准备到骨架制作,再到裱糊、绘制、放飞,形成了一个完整的学习闭环。课程的进度数据(100%、86%、58%、31%、8%)呈现出递减的规律,反映了学习过程中用户的逐步推进状态。


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

5.1 NAV_LIST:底部主导航的四区划分

NAV_LIST数组定义了应用底部的主导航栏,包含4个导航项:首页(🪁)、市集(🏮)、课堂(📚)、我的(👤)。这四个导航项将应用的功能划分为四大区域,形成了清晰的信息架构。

首页是应用的信息聚合中心,承载了精选内容推荐、数据概览、社区动态等多种信息,是用户进入应用后的第一站。市集是纸鸢商品的展示与交易中心,用户可以在这里浏览和定制各种风筝产品。课堂是学习中心,提供风筝制作与放飞的系统化课程。我的是个人中心,展示用户的基本信息、扎制档案与鸢语记录。

从图标设计来看,每个导航项都配有一个emoji图标,既统一了视觉风格,又增强了导航的可识别性。风筝emoji(🪁)对应首页,灯笼emoji(🏮)对应市集,书本emoji(📚)对应课堂,人物emoji(👤)对应我的。这些图标的选择都与各功能区的核心意象高度契合,用户能够快速建立图标与功能之间的联想。

从交互机制来看,底部导航的切换通过mainTab状态变量控制。当用户点击某个导航项时,mainTab被赋值为对应的索引值,mainContent()构建函数根据mainTab的值渲染对应的页面内容。当前选中的导航项会呈现不同的视觉状态——图标不透明度为1(未选中为0.55)、文字加粗且颜色为主色(未选中为提示灰),这种视觉反馈确保用户能够清晰地感知当前所在的页面位置。

5.2 SUB_NAV_LIST:首页子导航的七维内容矩阵

SUB_NAV_LIST数组定义了首页的子导航栏,包含7个内容Tab:精选、纸鸢橱窗、我的扎制、放飞工坊、风筝课堂、鸢友圈、器材库。这7个Tab构成了首页内容的七维矩阵,用户可以通过横向滑动或点击切换来浏览不同类别的内容。

从内容组织逻辑来看,7个Tab的排列遵循了"推荐-商品-服务-社区"的顺序:精选是综合推荐,纸鸢橱窗是商品展示,我的扎制是个人服务,放飞工坊是线下服务,风筝课堂是学习服务,鸢友圈是社区互动,器材库是配套商品。这种排列方式既符合用户的浏览习惯(先看推荐再看具体内容),又形成了从消费到服务再到社区的完整体验链路。

从视觉设计来看,子导航采用了"等分顶块式"的设计风格。每个Tab都占据等宽的空间(通过layoutWeight(1)实现),选中态有小圆点指示器+加粗主色文字+浅蓝背景,未选中态则为普通灰色文字+米色背景。这种顶块式设计与传统的下划线式Tab导航不同,它将选中项用背景色块突出显示,视觉上更加醒目,也更符合鸢语应用温润、饱满的设计调性。

从交互机制来看,子导航的切换通过subTab状态变量控制。由于7个Tab可能超出屏幕宽度,子导航被包裹在一个水平Scroll组件中,用户可以左右滑动查看所有Tab。滚动条被设置为隐藏状态(scrollBar(BarState.Off)),保持了界面的简洁性。

5.3 NavItem接口:导航数据的结构化

NavItem接口定义了导航项的数据结构,包含icon(图标)与label(标签文字)两个字段。这种接口化的设计使得导航配置数据与渲染逻辑完全分离——如果需要修改导航项的文字或图标,只需修改NAV_LIST数组中的数据即可,无需改动UI代码。同时,这种结构化的数据也便于后续扩展,比如增加角标、红点提示等功能时,只需在接口中增加对应的字段即可。

接口的设计遵循了最小化原则——只包含导航渲染所必需的两个字段,没有多余的冗余信息。这种简洁的接口设计使得代码更加清晰易懂,也减少了维护成本。


六、纯函数工具集:特效与状态的计算引擎

6.1 barH:柱状图高度的计算函数

barH函数用于计算柱状图中柱子的高度,是"本周放飞打卡次数"柱状图的核心计算函数。函数接收两个参数:v(当前值)与max(最大值),返回计算后的像素高度。

函数的计算公式为:Math.round(118 * v / max)。其中118是柱子的最大高度(以像素为单位),v / max是当前值占最大值的比例,两者相乘得到当前柱子的实际高度。Math.round函数用于对结果进行四舍五入取整,确保高度值为整数像素,避免出现模糊的渲染效果。

这个函数虽然简单,但体现了数据可视化的基本原则——将数据值映射为视觉属性(在这里是高度)。通过比例计算,数据的大小关系被准确地转化为视觉上的高低关系,用户能够直观地通过柱子的高度判断数据的大小。

6.2 kiteColor:热度颜色的映射函数

kiteColor函数根据热度值返回对应的颜色,用于柱状图柱子的颜色渲染。函数采用了三级阈值判断逻辑:当值大于10时返回天蓝色(#4A6FA5),当值大于6时返回橙红色(#D96C47),否则返回金橙色(#D9973B)。

这种颜色映射策略使得柱状图不仅能够通过高度表达数据的大小,还能够通过颜色表达数据的等级。高热度值(>10)用天蓝色表示,代表"优秀"等级;中热度值(>6)用橙红色表示,代表"良好"等级;低热度值(≤6)用金橙色表示,代表"一般"等级。这种"高度+颜色"的双重编码方式,增强了数据可视化的信息密度与可读性。

值得注意的是,三个颜色恰好对应了色彩系统中的主色、强调色与警告色,保持了整体视觉的一致性。同时,阈值的设定(10与6)也与WEEK_CHART数据中的实际分布相匹配——周末的高值(16、13)为天蓝色,周中的中值(9、5、4)为橙红色,工作日的低值(2、3)为金橙色。

6.3 kiteA/kiteX/kiteY/kiteAngle:风筝摇摆特效的计算引擎

这四个函数构成了风筝摇摆特效的计算引擎,它们分别计算风筝的透明度、X坐标、Y坐标与旋转角度。每个函数都接收两个参数:tick(时间节拍)与i(风筝索引),返回对应的计算值。

kiteA函数计算风筝的透明度,公式为:0.35 + ((tick + i) % 4) * 0.1。透明度的取值范围在0.35到0.65之间,以4个节拍为一个周期循环变化。不同索引的风筝由于i值的不同,会处于周期的不同阶段,从而形成错落有致的闪烁效果。

kiteX函数计算风筝的X坐标,公式为:60 + ((tick * 6 + i * 211) % 560)。X坐标的取值范围在60到620之间,形成横向的飘移运动。tick * 6控制了运动的速度(每节拍移动6个单位),i * 211则为不同风筝引入了相位偏移(211是一个质数,能够确保各风筝的运动轨迹不会轻易重合)。

kiteY函数计算风筝的Y坐标,公式为:70 + ((tick * 3 + i * 149) % 220)。Y坐标的取值范围在70到290之间,形成纵向的飘移运动。与X坐标相比,Y坐标的运动速度更慢(每节拍3个单位)、运动范围更小(220像素),这使得风筝的纵向运动更加柔和,符合风筝在空中随风飘荡的物理特性。

kiteAngle函数计算风筝的旋转角度,公式为:((tick * 5 + i * 47) % 40) - 20。角度的取值范围在-20度到+20度之间,形成左右摇摆的效果。旋转角度的变化与位置移动相结合,使得风筝的运动更加自然生动,仿佛真的在空中随风摇曳。

这四个函数共同构成了一个完整的运动学计算模型。通过时间节拍tick的驱动,三只风筝在屏幕上以不同的速度、不同的轨迹、不同的角度、不同的透明度进行着看似随机却又有规律的运动,营造出风筝在天空中自由飞翔的视觉效果。

6.4 ribbonX/ribbonY/ribbonA:丝带漂移特效的计算模型

丝带漂移特效由三个函数驱动:ribbonXribbonYribbonA。它们分别计算丝带的X坐标、Y坐标与透明度,与风筝特效的函数结构类似,但参数有所不同,从而形成了不同的运动特性。

ribbonX函数的公式为:((tick * 7 + i * 167) % 640) - 30。X坐标的取值范围在-30到610之间,运动速度为每节拍7个单位。与风筝相比,丝带的横向运动速度更快(7 vs 6),运动范围更大(640 vs 560),这使得丝带看起来更加轻盈飘逸。

ribbonY函数的公式为:120 + ((tick * 2 + i * 173) % 520)。Y坐标的取值范围在120到640之间,运动速度为每节拍2个单位。丝带的纵向运动范围非常大(520像素),几乎覆盖了整个屏幕的高度,这使得丝带能够在垂直方向上"穿梭"于界面内容之间,增强了层次感。

ribbonA函数的公式为:0.12 + ((tick + i * 2) % 5) * 0.06。透明度的取值范围在0.12到0.36之间,整体透明度较低。这种低透明度的设计是为了确保丝带不会干扰主要内容的阅读——丝带只是作为背景装饰存在,若隐若现,增添了界面的灵动感而又不会喧宾夺主。

丝带特效使用了6条丝带(索引0到5),比风筝多一倍。更多的运动元素加上更快的运动速度,使得背景特效更加丰富生动。同时,丝带的颜色使用了丝绸色(COLORS.silk),与风筝主题形成呼应,也为整体画面增添了一抹温暖的色调。

6.5 statusColor与statusProgress:扎制状态的色值与进度映射

statusColorstatusProgress两个函数用于将扎制订单的状态文本映射为对应的颜色值与进度百分比,是"我的扎制"页面中状态可视化的核心工具。

statusColor函数根据状态文本返回对应的颜色值。已交付对应草绿色(#7BA05B),扎制中对应天蓝色(#4A6FA5),裱糊中对应橙红色(#D96C47),绑扎中对应金橙色(#D9973B),其他未知状态对应提示灰色(#ADADB8)。四种主要状态分别对应了色彩系统中的成功色、主色、强调色与警告色,形成了清晰的语义关联——绿色代表完成、蓝色代表进行中、橙色代表待处理、金色代表准备中。

statusProgress函数根据状态文本返回对应的进度百分比。已交付对应100%,扎制中对应72%,裱糊中对应55%,绑扎中对应38%,其他状态对应10%。这些进度值并非随意设定,而是根据风筝扎制的实际工艺流程来确定的——设计与准备阶段约占10%,绑扎骨架约占38%,裱糊蒙面约占55%,整体组装与调试约占72%,最终交付为100%。这种基于实际工艺的进度划分使得状态展示更加专业可信。

两个函数配合使用,在"我的扎制"页面中形成了"状态标签+进度条"的双重状态展示。状态标签用文字和颜色直观地告诉用户当前的工序阶段,进度条则用百分比量化了完成程度,两者相辅相成,提供了完整的状态信息。

6.6 kiteTagColor:风筝类型的颜色标记函数

kiteTagColor函数根据风筝名称中的关键字返回对应的颜色值,用于纸鸢市集中的品类色条与热度进度条的颜色渲染。

函数的判断逻辑基于风筝名称的关键词匹配:如果名称中包含"沙燕"或"瘦燕",则返回天蓝色(#4A6FA5);如果包含"蜈蚣"或"串",则返回橙红色(#D96C47);如果包含"蝴蝶"或"鹰",则返回草绿色(#7BA05B);其他情况返回金橙色(#D9973B)。

这种基于名称关键词的颜色分类方式非常巧妙——它无需在数据模型中额外增加类型字段,而是通过名称中的固有信息来推断风筝的类型。这既是一种代码层面的简化,也反映了传统风筝命名的特点——名称中通常包含品类信息,有经验的玩家仅凭名称就能判断风筝的类型。

四种颜色分别对应了四大风筝品类:硬翅类(沙燕、瘦燕)为天蓝色,串式类(蜈蚣、串)为橙红色,软翅类(蝴蝶、鹰)为草绿色,其他(板子类、立体类等)为金橙色。这种颜色编码与收藏类别构成图(KITE_CHART)中的颜色系统完全一致,形成了跨页面的统一视觉语言。

6.7 levelColor:难度等级的颜色体系函数

levelColor函数根据风筝的难度等级返回对应的颜色值,用于等级标签的文字颜色渲染。

函数将等级分为四档:入门对应草绿色(#7BA05B),进阶对应天蓝色(#4A6FA5),高手对应金橙色(#D9973B),收藏级对应橙红色(#D96C47),未知等级对应深红色(#C0503F)。

这种颜色分级体系与难度等级形成了渐进的视觉关联:从绿色(入门,简单)到蓝色(进阶,中等),再到金色(高手,较难),最后到橙红色(收藏级,极难),颜色的暖度与饱和度逐渐增加,视觉冲击力也逐渐增强。用户在浏览风筝时,能够通过等级标签的颜色快速判断风筝的难度级别,选择适合自己的产品。

值得注意的是,等级颜色与功能色系统也存在一定的对应关系:入门级使用成功色(绿色),暗示"容易上手";进阶级使用主色(蓝色),代表"主流水平";高手级使用警告色(金色),表示"需要注意";收藏级使用强调色(橙红色),突出"珍贵稀有"。这种语义上的呼应使得整个色彩系统更加统一、更有逻辑。


七、组件状态管理:@State变量与生命周期的协同运作

7.1 组件状态变量全景

PageKiteCrafting组件内部定义了丰富的@State状态变量,这些变量构成了组件的状态管理中枢。按照功能维度,可以将这些状态变量分为六大类:导航状态、特效状态、弹框状态、编辑数据、列表数据与定时器引用。

导航状态由mainTabsubTab两个变量组成。mainTab控制底部主导航的切换(0-3分别对应首页、市集、课堂、我的),subTab控制首页子导航的切换(0-6分别对应七个内容Tab)。这两个变量是界面路由的核心,它们的值决定了当前显示的页面内容。

特效状态由tickglow两个变量组成。tick是特效动画的时间节拍计数器,每90毫秒递增1次,驱动着风筝摇摆与丝带漂移特效的运动。glow是一个0-1循环的闪烁计数器,虽然在当前代码中未直接使用,但预留了呼吸灯、光晕闪烁等特效的扩展能力。

弹框状态由addOpeneditOpendelOpenbookOpen四个布尔变量组成,分别控制发布鸢语弹框、编辑扎制单弹框、删除确认弹框与预约场地弹框的显示与隐藏。每个弹框独立控制,互不干扰,但弹框之间是互斥显示的——同一时间只会有一个弹框出现在界面上。

编辑数据状态由editIdxeditNameeditStyleeditNoteaddTitleaddContentbookFieldbookTimedelTarget等变量组成。这些变量用于暂存弹框表单中的输入数据,在用户确认操作后,这些数据会被写回对应的列表数据中。delTarget变量尤为特殊,它用于区分删除弹框的删除目标——是取消扎制订单(‘craft’)还是退出风筝课堂(‘course’),实现了一个弹框服务于两种场景的复用设计。

列表数据状态由postListcraftList两个数组变量组成,分别存储鸢友动态列表与扎制订单列表。这两个数组被@State装饰,因此数组的变化(新增、删除、替换元素)能够触发UI的自动更新。数组的元素类型分别为PostItemCraftItem,两者都被@Observed装饰,因此元素内部属性的变化也能够被观察到。

定时器引用fxTimer存储特效定时器的ID,用于在组件销毁时清除定时器,防止内存泄漏。这个变量虽然被@State装饰,但实际上它并不参与UI渲染,仅仅是作为组件实例的一个属性来存储定时器引用。

7.2 生命周期钩子:特效的启动与销毁

aboutToAppearaboutToDisappear是ArkUI框架提供的两个组件生命周期钩子函数,分别在组件即将显示与即将消失时被调用。在鸢语应用中,这两个钩子函数承担着特效定时器的启动与销毁职责。

aboutToAppear中,通过setInterval创建了一个每90毫秒触发一次的定时器。定时器的回调函数做了两件事:一是将tick加1,推动特效动画的时间线前进;二是将glow循环加1(对2取模),实现0-1的闪烁切换。90毫秒的间隔意味着特效动画的帧率约为11fps,这个帧率既能够保证动画的流畅性,又不会过度消耗系统资源。

aboutToDisappear中,通过clearInterval清除之前创建的定时器,并将fxTimer重置为-1。这个清理操作至关重要——如果不在组件销毁时清除定时器,定时器会继续运行,导致内存泄漏与性能损耗。通过fxTimer > 0的判断,确保了只有在定时器有效时才执行清除操作,避免了清除无效定时器可能引发的错误。

这种"在组件出现时启动、在组件消失时停止"的生命周期管理模式,是ArkUI中处理定时器、事件订阅等资源的标准范式。它确保了资源的正确分配与释放,体现了良好的工程实践。

7.3 操作方法:增删改查的业务逻辑

组件内部定义了一系列操作方法,用于处理用户的各种交互操作,构成了完整的业务逻辑层。这些方法包括:openAdd(打开发布弹框)、openEdit(打开编辑弹框)、openDelA(打开取消订单弹框)、openDelB(打开退课弹框)、openBook(打开预约弹框)、doAdd(执行发布)、doEdit(执行编辑)、doDel(执行删除)与doBook(执行预约)。

openAdd方法在打开发布弹框之前,先将addTitleaddContent重置为空字符串,确保每次打开弹框时都是空白的表单。这种"打开前重置"的模式是表单交互的标准做法,能够避免上次输入的数据对下次操作造成干扰。

openEdit方法接收一个索引参数index,根据索引从craftList中获取对应的扎制订单数据,将订单的名称、状态与内容分别赋值给editNameeditStyleeditNote,然后打开编辑弹框。方法中包含了边界检查(index >= 0 && index < this.craftList.length),确保只有在索引有效时才进行数据填充,避免了数组越界错误。

openDelAopenDelB两个方法分别用于打开取消订单弹框与退课弹框,但实际上它们打开的是同一个删除弹框(delOpen),区别仅在于delTarget的值不同。这种"一个弹框、多种用途"的复用设计,减少了代码冗余,同时也统一了删除操作的交互体验。

doAdd方法执行发布操作,首先检查标题是否非空(this.addTitle.length > 0),然后使用unshift方法将新的PostItem插入到postList数组的最前面(即动态列表的顶部),最后关闭弹框。新动态的ID固定为999,发布者为"我的鸢语",头像为风筝emoji,发布时间为"刚刚",点赞数为0,这些默认值确保了新动态在列表中能够被正确识别为用户自己发布的内容。

doEdit方法执行编辑操作,使用splice方法将指定索引的元素替换为新的CraftItem实例。方法中同样包含了边界检查,确保编辑操作的安全性。编辑后订单的进度被重置为40%,这是一个合理的默认值——因为用户修改了订单信息,意味着订单可能需要重新评估进度。

doDel方法执行删除操作,根据delTarget的值判断删除目标:如果是取消订单(‘craft’),则从craftList中删除第一个元素;如果是退课(‘course’),则从postList中删除第一个元素。删除第一个元素的设计简化了交互逻辑,用户无需选择具体要删除的项目,直接删除首项即可。

doBook方法执行预约操作,检查场地字段是否非空后,将预约信息以鸢语动态的形式插入到postList的顶部。这种设计使得预约操作的结果能够在鸢友圈中被看到,增强了社区的互动性与用户的参与感。


八、fxLayer特效层:风筝摇摆与丝带漂移的视觉魔法

8.1 特效层的架构设计

fxLayer(特效层)是鸢语应用中最具视觉特色的部分之一,它通过纯代码的方式实现了风筝摇摆与丝带漂移的动态背景效果。特效层的架构设计遵循了"底层装饰、不阻交互"的原则——特效位于界面的最底层(仅在背景色之上),并且不拦截任何触摸事件,确保用户与主内容区的交互不受任何影响。

从结构上看,特效层使用Stack布局作为容器,内部包含两个ForEach循环:第一个循环生成3只风筝,第二个循环生成6条丝带。两个循环的元素都通过translate变换属性定位到Stack中的不同位置,形成错落有致的分布效果。Stack的对齐方式设置为Alignment.TopStart(左上角对齐),这意味着translate的坐标原点位于屏幕的左上角,便于精确控制元素的位置。

特效层最关键的属性是hitTestBehavior(HitTestMode.None)。这个属性设置使得特效层完全穿透触摸事件——用户点击到特效层上的任意位置,事件都会直接传递到下方的主内容区。这种穿透设计是特效层能够作为背景装饰而不干扰用户操作的核心保障。如果没有这个属性,特效层虽然在视觉上是半透明的,但在触摸事件层面仍然会阻挡下方的内容,导致用户无法正常点击按钮、滚动列表等。

8.2 风筝摇摆特效的实现细节

风筝摇摆特效由3只风筝emoji组成,每只风筝都有独立的运动轨迹与动画参数。风筝使用Text组件渲染,内容为风筝emoji(🪁),字体大小为24像素。

每只风筝的动画由四个属性共同驱动:rotate(旋转)、opacity(透明度)、translate.x(横向位移)与translate.y(纵向位移)。这四个属性分别对应四个计算函数(kiteAnglekiteAkiteXkiteY)的返回值,而这些函数又都依赖于tick状态变量。当tick每90毫秒递增一次时,四个函数的返回值都会发生变化,从而驱动风筝的位置、角度与透明度同步更新,形成流畅的动画效果。

ForEach的键值生成函数使用了索引的字符串形式(i.toString()),这是因为风筝元素是纯装饰性的,没有业务上的唯一标识。使用索引作为键值能够确保框架正确地复用节点,避免不必要的销毁与重建。

8.3 丝带漂移特效的实现细节

丝带漂移特效由6条丝带组成,每条丝带都是一个高度为3像素、宽度为28像素的圆角矩形。丝带使用Column组件渲染,通过设置widthheightborderRadiusbackgroundColor属性来实现条状的视觉效果。丝带的颜色为丝绸色(COLORS.silk),这与风筝主题形成了巧妙的呼应。

每条丝带的动画由三个属性驱动:opacity(透明度)、rotate(旋转)与translate(位移)。ribbonA函数计算透明度,ribbonXribbonY函数计算位移,而旋转角度则固定为14度。14度的倾斜角度使得丝带看起来像是在风中飘动的状态,增强了真实感。

丝带的数量(6条)比风筝(3只)多一倍,这是因为丝带作为更细碎的装饰元素,需要更多的数量才能营造出丰富的视觉效果。同时,丝带的运动速度更快、运动范围更大、透明度更低,这使得丝带在视觉上处于比风筝更远的"背景层",形成了远近层次感。

8.4 性能考量与优化策略

特效层虽然视觉效果丰富,但在性能上做了多方面的优化,确保动画流畅运行的同时不会过度消耗系统资源。

首先,动画的驱动方式采用了单一的tick计数器,而非为每个动画元素单独创建定时器。只使用一个setInterval来驱动所有元素的动画,大大减少了定时器的数量,降低了CPU的调度开销。所有元素的动画参数都通过纯函数计算得出,这些计算都是简单的算术运算,执行速度非常快。

其次,动画属性的选择充分利用了GPU的硬件加速能力。translate(位移)、rotate(旋转)与opacity(透明度)这三个属性都属于"合成属性"——它们的变化不会触发布局(Layout)与绘制(Draw)阶段,只需要在合成(Composite)阶段由GPU完成变换即可。这意味着动画的运行效率非常高,即使在中低端设备上也能够流畅播放。

再次,特效元素的数量被控制在合理范围内(3只风筝+6条丝带=9个元素),既保证了视觉效果的丰富度,又不会因为元素过多而导致渲染压力过大。同时,元素使用的是emoji与简单的几何形状(矩形),无需加载任何图片资源,减少了内存占用与绘制开销。

最后,特效层的hitTestBehavior(HitTestMode.None)设置不仅确保了交互的穿透性,也在事件分发层面减少了性能开销——系统不需要为特效层的元素进行命中测试,直接跳过即可。


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

9.1 头部区域的整体结构

header构建函数负责渲染页面的头部区域,它是用户进入应用后最先看到的内容,承担着品牌展示、信息概览与快捷入口的多重职责。头部区域采用垂直布局(Column),内部从上到下依次排列了四个模块:品牌行+风况定位、放飞窗口卡、大赛横幅与数据四格。四个模块之间通过10像素的间距分隔,形成清晰的视觉层次。

从信息架构的角度来看,头部区域遵循了"品牌-核心信息-活动-数据"的递减逻辑。最上方是品牌信息,建立用户的产品认知;其次是核心功能信息(放飞窗口),满足用户的核心需求;再次是活动推广信息,引导用户参与活动;最后是数据概览,让用户了解自己的使用情况。这种由重到轻的信息排列方式,符合用户的视觉浏览路径与信息获取习惯。

9.2 品牌行与风况定位

品牌行是头部的第一个模块,采用水平布局(Row),从左到右依次是风筝emoji图标、品牌名称与位置信息的组合、打卡按钮。

品牌图标使用24像素的风筝emoji(🪁),与应用的主题高度契合。品牌名称"鸢语纸鸢"使用16号粗体深灰色文字,位置信息"📍 滨河放飞场 · 东南风 3 级 · 宜放硬翅"使用10号灰色文字。两者组合在一个Column中并占据剩余空间(layoutWeight(1)),形成了左对齐的品牌信息块。

位置信息的设计非常巧妙,它不仅显示了当前的放飞场地,还包含了实时的风力信息(东南风3级)与放飞建议(宜放硬翅)。对于风筝爱好者来说,风力条件是决定能否放飞、适合放什么类型风筝的关键因素,将这些信息放在头部最显眼的位置,体现了应用"以用户为中心"的设计理念。

打卡按钮位于品牌行的最右侧,使用橙红色背景配白色粗体文字,圆角为14像素,按钮内边距为上下7像素、左右14像素。橙红色的按钮在米灰色的头部背景上非常醒目,引导用户点击进行打卡操作。点击打卡按钮会打开发布鸢语弹框(调用openAdd方法),这意味着"打卡"本质上就是发布一条鸢语动态,两者共享同一个交互入口。

9.3 放飞窗口卡:核心信息的视觉强化

放飞窗口卡是头部区域最醒目的模块,它采用橙红色(COLORS.accent)作为背景色,配合白色文字,形成了强烈的视觉冲击力。卡片使用16像素的圆角,内边距为14像素,整体呈现出饱满、温暖的视觉感受。

卡片内部采用水平布局,左侧是放飞信息,右侧是预约按钮。信息部分包含三行内容:第一行是"今日放飞窗口 14:00-18:00",使用15号粗体白色文字,这是最核心的信息;第二行是"风力 3 级 · 阵风 5 级 · 硬翅可上 2.4 米",使用10号浅橙色(#FBEFE9)文字,提供更详细的风力数据与放飞建议;第三行是"适宜指数"标签与"86 分 · 全场可放"的组合,使用9号文字,进一步量化了放飞条件。

"适宜指数"标签的设计是卡片中的一个亮点。标签使用丝绸色(COLORS.silk)作为背景,深天蓝色(COLORS.primaryDark)作为文字颜色,6像素的圆角与2像素的内边距。丝绸色与橙红色的搭配既和谐又有对比,深天蓝色的文字在丝绸色背景上有足够的对比度,确保了可读性。这种色彩搭配充满了传统工艺的韵味,与鸢语应用的品牌调性高度契合。

预约按钮位于卡片的右侧,使用丝绸色背景配深天蓝色粗体文字,14像素的圆角与8像素的内边距。点击预约按钮会打开预约场地弹框(调用openBook方法),用户可以填写场地与时段信息进行预约。按钮的颜色与"适宜指数"标签保持一致,形成了视觉上的呼应。

9.4 大赛横幅:活动推广的轻量展示

大赛横幅是一个轻量级的活动推广模块,采用白色卡片背景(COLORS.cardBg),14像素的圆角,12像素的内边距。横幅内部采用水平布局,从左到右依次是灯笼emoji图标、活动信息与报名按钮。

活动信息包含两行文字:第一行是"沙燕复刻赛开启报名",使用13号粗体深灰色文字,是活动的主题;第二行是"老师傅带队三周 · 附送老款图纸一套",使用10号灰色文字,是活动的亮点说明。这种"主题+亮点"的文案结构能够在有限的空间内快速传达活动的核心价值,吸引用户参与。

报名按钮位于横幅的最右侧,使用天蓝色背景配白色粗体文字,14像素的圆角与7像素的内边距。天蓝色的报名按钮与橙红色的打卡按钮形成了色彩上的区分——橙红色代表"我的操作"(打卡、发布),天蓝色代表"参与活动"(报名、预约)。这种色彩语义的区分虽然细微,但却能够在用户潜意识中建立起颜色与操作类型之间的关联。

9.5 数据四格:用户成就的可视化

数据四格是头部区域的最后一个模块,以四个等宽小卡片的形式展示用户的核心数据指标。四个卡片使用水平布局(Row)排列,每个卡片都有白色背景、10像素圆角与8像素内边距,卡片之间有8像素的间距。

四个数据指标分别是:收藏纸鸢(26只,天蓝色)、扎制件数(15件,橙红色)、放飞时长(208h,草绿色)与鸢币(¥72,金橙色)。每个指标都由两行动态文字组成:上方是9号灰色的指标名称,下方是13号粗体彩色的指标数值。

四个数值分别使用了不同的颜色,且这些颜色都来自色彩系统中的语义色:收藏纸鸢使用主色(天蓝色),代表核心收藏;扎制件数使用强调色(橙红色),代表核心创作;放飞时长使用成功色(草绿色),代表健康活动;鸢币使用警告色(金橙色),代表财富激励。这种色彩编码方式使得每个数据指标都有独特的视觉标识,用户能够快速识别与记忆。

数据四格的设计不仅展示了用户的使用数据,更重要的是构建了用户的成就感与激励体系。用户通过查看自己的收藏数量、扎制件数、放飞时长与鸢币余额,能够直观地感受到自己在应用中的成长与积累,从而增强用户粘性与使用意愿。


十、subNav子导航:等分顶块式的内容切换

10.1 子导航的结构设计

subNav构建函数负责渲染首页的子导航栏,它采用了"等分顶块式"的设计风格,是鸢语应用界面的一大特色。子导航被包裹在一个水平滚动的Scroll组件中,内部使用Row布局排列7个Tab项,每个Tab项通过layoutWeight(1)实现等宽分配。

Scroll组件的滚动方向设置为水平(ScrollDirection.Horizontal),滚动条设置为隐藏(BarState.Off),上下内边距分别为6像素与10像素。水平滚动的设计确保了即使在较窄的屏幕上,7个Tab也能够全部展示——用户可以通过左右滑动来查看被遮挡的Tab。隐藏滚动条则保持了界面的简洁性,避免了滚动条对视觉的干扰。

10.2 Tab项的视觉设计

每个Tab项使用Column布局,由上下两部分组成:上方是一个6像素的小圆点指示器,下方是Tab文字。这种"圆点+文字"的组合既简洁又富有设计感。

小圆点指示器的设计非常精巧。选中态的圆点为橙红色(COLORS.accent),6像素见方,3像素的圆角(即正圆形),位于Tab项的顶部中央。未选中态的圆点虽然同样是6像素见方,但透明度为0,即完全不可见。这种设计使得选中态有一个清晰的视觉标记,而未选中态则没有多余的视觉元素,保持了界面的整洁。

Tab文字使用11号字体,选中态为粗体天蓝色(COLORS.primary),未选中态为常规字重灰色(COLORS.textSecondary)。文字的最大行数限制为1行,确保Tab文字不会换行。选中与未选中的状态差异通过字重与颜色两个维度来体现,比单一维度的变化更加明显,用户能够快速识别当前选中的Tab。

10.3 选中态的背景色块

子导航最具特色的设计是选中态的背景色块。选中的Tab项背景色为浅天蓝色(COLORS.primaryLight),未选中的Tab项背景色为页面背景色(COLORS.bg),两者形成柔和的对比。Tab项使用10像素的圆角,上下内边距为6像素,整体呈现出"顶块"的视觉效果。

这种"等分顶块式"的设计与传统的"下划线式"Tab导航相比,有几个优势:一是视觉上更加饱满、温润,与鸢语应用整体的设计调性更加契合;二是选中态的视觉反馈更加明显,用户能够一目了然地看到当前所在的位置;三是点击热区更大——整个顶块都是可点击区域,提高了点击的便利性。

Tab项的点击事件通过onClick回调处理,点击时将subTab赋值为当前索引。由于subTab@State装饰的状态变量,赋值后框架会自动更新UI,重新渲染子导航的选中态与对应的页面内容。


十一、七大页面Builder:内容生态的模块化构建

11.1 pageFeatured:精选内容聚合页

pageFeatured构建函数负责渲染首页的精选内容页,它是用户进入应用后看到的第一个内容页面,承担着信息聚合与功能导流的双重职责。精选页采用垂直布局(Column),内部从上到下依次排列了四个模块:玩法四格、本周放飞柱状图、收藏类别构成图、人气纸鸢双列与鸢友动态。

玩法四格模块展示了四种核心玩法(扎制工坊、放飞活动、纸鸢市集、复刻课堂),每个玩法都有图标、名称、描述与热门标签。四个玩法入口使用水平布局等宽排列,每个入口都是一个白色卡片,卡片内部使用垂直布局居中对齐。热门玩法(扎制工坊与放飞活动)会显示红色的"热门"标签,非热门玩法则显示一个空格占位,确保布局的一致性。

本周放飞柱状图模块使用白色卡片容器,内部包含标题行、柱状图与周末占比统计。柱状图通过ForEach遍历WEEK_CHART数据,每天对应一根柱子。柱子的高度由barH函数计算,颜色由kiteColor函数决定,形成了"高度+颜色"的双重数据编码。柱状图下方是周末放飞占比的统计文字,用橙红色突出显示63%的比例。

收藏类别构成图模块同样使用白色卡片容器,内部以横向进度条的形式展示五大风筝类别的收藏占比。每个类别一行,从左到右依次是类别名称、进度条与百分比数值。进度条使用Stack布局实现——底层是灰色的背景条(COLORS.border),上层是彩色的进度条,进度条的宽度由百分比值决定。五种颜色分别对应五大类别,与全局的色彩编码系统保持一致。

人气纸鸢双列模块展示了人气最高的8款风筝,采用双列瀑布流布局。布局的实现方式是在ForEach中通过idx % 2 === 0的判断,每次处理一对风筝(第idx个与第idx+1个),将它们放在同一个Row中。每个风筝都是一个白色卡片,卡片内部包含图标、等级标签、名称与价格热度信息。双列布局在有限的垂直空间内展示了更多的内容,提高了信息密度。

鸢友动态模块展示了最新的3条社区动态。每条动态都是一个白色卡片,使用水平布局——左侧是用户头像emoji,右侧是用户昵称、时间、正文与点赞数的组合。动态列表只展示前3条(this.postList.slice(0, 3)),既展示了社区的活跃度,又不会占用过多的屏幕空间。

11.2 pageMarket:纸鸢橱窗列表页

pageMarket构建函数负责渲染纸鸢市集页面,展示所有可定制的风筝产品。页面顶部是标题栏,包含"纸鸢橱窗"的标题与"定制我的纸鸢"的操作按钮。标题栏下方是风筝列表,使用ForEach遍历KITE_LIST数据。

每个风筝卡片都有独特的左侧色条设计。色条是一个4像素宽、38像素高的竖条,使用圆角矩形(2像素圆角),颜色由kiteTagColor函数根据风筝名称决定。色条的设计既为卡片增添了视觉特色,又通过颜色编码快速传达了风筝的品类信息——用户扫一眼色条的颜色就能知道风筝的大致品类。

卡片内容部分包含三行:第一行是风筝名称与等级标签的组合,名称使用粗体文字并占据剩余空间,等级标签则使用带背景色的小标签样式;第二行是价格与工艺的组合,分别使用硬币emoji与风emoji作为前缀;第三行是热度进度条与关注度数值。热度进度条同样使用双层Stack实现,进度条的宽度为hot值(百分比),颜色与左侧色条一致。

卡片底部有一行操作区,左侧显示关注度数值,右侧是"立即定制"按钮。"立即定制"按钮使用天蓝色背景配白色粗体文字,8像素的圆角与4像素的内边距。点击按钮会打开预约场地弹框,用户可以填写定制需求。

11.3 pageCrafts:我的扎制订单页

pageCrafts构建函数负责渲染"我的扎制"页面,展示用户的扎制订单列表。页面结构比较简洁——顶部是"我的扎制"标题,下方是订单列表。

每个扎制订单卡片包含三部分内容:顶部信息行、中间进度条与底部操作行。顶部信息行使用水平布局,左侧是剪刀emoji图标,右侧是订单名称、状态标签与扎制内容的组合。状态标签使用浅橙红色背景(COLORS.accentLight)配对应颜色的文字,颜色由statusColor函数决定。

进度条部分使用Stack实现双层进度条——底层是灰色背景条,上层是彩色进度条。进度条的宽度百分比与颜色都由statusProgressstatusColor函数根据订单状态决定。这种进度条与状态标签的联动设计,使得订单状态既有文字描述,又有可视化的进度展示,信息传达更加直观。

底部操作行左侧显示扎制进度的百分比数值,右侧是"编辑扎制单"链接。点击"编辑扎制单"会调用openEdit方法打开编辑弹框,用户可以修改订单的名称、状态与内容。编辑功能的设计让用户能够随时更新订单信息,保持数据的准确性。

11.4 pageWorkshop:放飞工坊页面

pageWorkshop构建函数负责渲染放飞工坊页面,这是一个功能丰富的页面,包含工位状态、人气排行榜与器材速购三个模块。

工位状态模块是工坊页面的特色功能,使用四格数据卡的形式展示当前的工位信息。四个指标分别是:当前工位(扎制台W2,天蓝色)、竹条烘温(120℃,橙红色)、工序(绑扎,金橙色)与今日工时(4.5h,草绿色)。四个指标分别使用了不同的语义色,与数据四格的色彩编码保持一致。工位状态模块让用户能够直观地了解自己当前的扎制工作状态,增强了"工坊"的沉浸感。

人气排行榜模块展示了人气最高的5款风筝(KITE_LIST.slice(0, 5))。排行榜的每一行包含排名数字、风筝信息与关注度三个部分。前三名的排名数字使用天蓝色(主色),第四名及以后使用灰色。这种颜色区分突出了前三名的"领奖台"地位,增强了排行榜的竞技感与趣味性。

器材速购模块展示了8种器材配件,采用双列布局(与人气纸鸢双列的实现方式相同)。每个器材卡片包含图标、名称、描述与"加购"链接。器材速购模块为用户提供了便捷的器材采购入口,用户在扎制过程中如果需要补充器材,可以快速下单。

11.5 pageCourse:风筝课堂页面

pageCourse构建函数负责渲染风筝课堂页面,展示用户的课程学习进度。页面包含课程列表与退课按钮两部分。

课程列表使用ForEach遍历COURSE_LIST数据。每门课程都是一个白色卡片,包含三行内容:顶部是课程标题与周次的组合,中间是进度条,底部是"已学"文字与进度百分比的组合。进度条的颜色与每门课程的主题色对应(竹材为天蓝、绑扎为橙红、裱糊为草绿、纹样为金橙、放飞为丝绸棕),形成了视觉上的区分。

退课按钮的设计比较特殊——它不是使用标准的Button组件文字属性,而是使用了一个透明的Button组件叠加Text文字的方式。按钮背景为深红色(COLORS.danger),高度42像素,圆角10像素,透明度0.9。文字"退课"使用白色14号字体,通过负外边距(margin({ left: -28, top: -32 }))定位到按钮的中心位置。这种实现方式虽然略显迂回,但达到了自定义按钮文字的效果。点击退课按钮会调用openDelB方法打开删除确认弹框,用户确认后退出首门课程。

11.6 pageCircle:鸢友圈社区页

pageCircle构建函数负责渲染鸢友圈页面,展示完整的社区动态列表。页面顶部是标题栏,包含"鸢友圈"的标题与"发鸢语"的操作按钮。标题栏下方是完整的动态列表(与精选页只展示前3条不同,鸢友圈展示全部动态)。

每条动态的卡片结构与精选页类似——左侧是用户头像emoji(26像素,比精选页略大),右侧是用户昵称、发布时间、动态正文与互动信息的组合。互动信息包含点赞数(🪁图标+数字)与回复入口(💬回复)。

鸢友圈页面的设计体现了社区应用的核心特征——以用户生成内容(UGC)为主体,通过点赞、回复等互动功能促进用户之间的交流。虽然当前代码中回复功能尚未完整实现(只显示文字,没有点击事件),但它为后续的社区互动功能扩展预留了入口。

11.7 pageGear:器材库页面

pageGear构建函数负责渲染器材库页面,展示风筝制作与放飞的器材配件。页面包含三大分区入口、器材列表与"取消扎制订单"按钮三部分。

三大分区入口使用三列布局展示扎制区、绑扎区与放飞区,每个分区都是一个白色卡片,包含图标(28像素)、分区名称与简要描述。三大分区的设计为器材库提供了分类导航,用户可以根据自己的需求快速定位到对应的器材类别。

器材列表展示了全部8种器材,采用单列列表布局。每种器材都是一个白色卡片,使用水平布局——左侧是器材图标emoji(20像素),中间是器材名称与描述,右侧是"加购"按钮。"加购"按钮使用天蓝色背景配白色粗体文字,8像素的圆角与3像素的内边距。单列列表的展示方式比双列布局更加清晰,适合器材这种需要详细阅读描述的商品类型。

"取消扎制订单"按钮与退课按钮的实现方式相同——透明Button叠加Text文字。按钮为深红色背景,点击后调用openDelA方法打开删除确认弹框。这个按钮的位置比较特殊——它被放在器材库页面的底部,但功能却是取消扎制订单。这种设计可能是出于功能入口布局的考虑,将"删除/取消"类的操作放在不那么显眼的位置,避免用户误操作。

11.8 pageMine:个人中心页面

pageMine构建函数负责渲染"我的"页面,展示用户的个人信息与数据概览。页面包含三个模块:用户信息卡、扎制档案与我的鸢语。

用户信息卡是页面的顶部模块,使用水平布局——左侧是40像素的风筝emoji头像,右侧是用户昵称与会员信息的组合。用户昵称为"鸢语扎制人",使用18号粗体深灰色文字;会员信息为"白银会员 · 放飞 208 h · 收藏 26 只 · 打卡 76 天",使用11号灰色文字。信息卡使用白色背景、14像素圆角与14像素内边距,整体呈现出简洁大方的视觉效果。

扎制档案模块展示用户的扎制订单概况,包含标题行与前3条订单的简略信息。标题行左侧是"我的扎制档案"的标题,右侧是订单总数(${this.craftList.length} 单)。订单列表只展示订单名称与状态,状态文字使用statusColor函数着色,让用户能够快速了解订单的状态分布。

我的鸢语模块展示用户发布的鸢语动态概况,结构与扎制档案类似——标题行显示动态总数,下方展示前3条动态的简略信息(昵称与时间)。这个模块让用户能够快速查看自己发布的动态记录,增强了个人中心的信息密度。


十二、弹框组件:CRUD交互的模态体验

12.1 弹框系统的架构设计

鸢语应用设计了完整的弹框系统,包含四种功能弹框:发布鸢语(新增)、编辑扎制单(编辑)、取消订单/退课(删除)与预约场地(预约)。四种弹框覆盖了数据操作的CRUD全场景,形成了完整的交互闭环。

从架构上看,弹框系统由三部分组成:modalOverlay(遮罩层)、四种弹框体(addModalBodyeditModalBodydelModalBodybookModalBody)以及弹框状态变量(addOpeneditOpendelOpenbookOpen)。遮罩层是所有弹框共享的底层组件,弹框体则是各自独立的内容区域。

弹框的显示与隐藏通过条件渲染(if (this.addOpen)等)来控制。当弹框状态变量为true时,弹框被渲染到组件树中;当状态变量为false时,弹框从组件树中移除。这种条件渲染的方式确保了弹框在不显示时不会占用渲染资源,同时也避免了隐藏状态下的事件监听与内存占用。

12.2 modalOverlay:遮罩层的统一实现

modalOverlay构建函数负责渲染弹框的遮罩层,它是所有弹框共享的底层组件。遮罩层使用Stack布局,内部是一个填满整个屏幕的Column组件,背景色为黑色(#000000),透明度为0.6。

遮罩层有两个作用:一是视觉上的暗化效果——将弹框下方的内容变暗,突出弹框本身,引导用户将注意力集中到弹框上;二是交互上的阻挡效果——遮罩层会吸收点击事件,防止用户点击到弹框下方的内容。

遮罩层本身也有点击事件——点击遮罩层会关闭所有弹框(将四个弹框状态变量都设为false)。这种"点击遮罩关闭弹框"的交互模式是移动端UI的标准设计,它为用户提供了一个便捷的关闭弹框的方式,减少了用户的操作成本。

12.3 addModalBody:发布鸢语弹框

addModalBody构建函数负责渲染发布鸢语弹框的内容区域。弹框体使用垂直布局,包含标题、两个输入框与底部操作按钮。

标题"发布鸢语"使用17号粗体深灰色文字,位于弹框的顶部中央。标题下方是两个输入控件:第一个是TextInput单行输入框,用于输入鸢语的标题(或一句话内容),占位符为"一句话放飞日常…“;第二个是TextArea多行输入框,用于输入详细内容,占位符为"补充细节:风况、纸鸢、放飞心得…”,高度为90像素。两个输入框都使用米色背景(COLORS.bg)、8像素圆角与13号字体。

底部操作按钮使用水平布局,包含"取消"与"发布"两个按钮。取消按钮使用米色背景配灰色文字,发布按钮使用天蓝色背景配白色粗体文字。两个按钮各占一半宽度(layoutWeight(1)),按钮高度为38像素,圆角为8像素。

弹框体的宽度为父容器的100%,内边距为16像素,背景为白色。在实际使用中,弹框体被包裹在一个宽度为88%的容器中,最大高度限制为屏幕高度的80%,圆角为16像素,z-index设为999确保弹框在最上层。

12.4 editModalBody:编辑扎制单弹框

editModalBody构建函数负责渲染编辑扎制单弹框的内容区域。弹框的结构与发布鸢语弹框类似——标题、输入框、操作按钮,但输入内容不同。

标题为"编辑扎制单",使用17号粗体深灰色文字。输入区域包含三个控件:第一个是TextInput单行输入框,用于编辑扎制名称,占位符为"扎制名称",初始值为editName;第二个是TextInput单行输入框,用于编辑状态,占位符为"状态(如 绑扎中)“,初始值为editStyle;第三个是TextArea多行输入框,用于编辑扎制内容与备注,占位符为"扎制内容与备注说明”,高度为70像素,初始值为editNote

底部操作按钮同样是"取消"与"保存"的组合,样式与发布弹框一致。点击"保存"会调用doEdit方法,将编辑后的数据写回到craftList数组中对应的位置。

编辑弹框与发布弹框的结构高度相似,这体现了表单弹框的设计一致性。统一的弹框结构降低了用户的学习成本——用户在使用过一次弹框后,就能快速理解其他弹框的操作方式。

12.5 delModalBody:删除确认弹框

delModalBody构建函数负责渲染删除确认弹框的内容区域。与前两种弹框相比,删除弹框的结构更加简单——只有标题、提示文字与操作按钮,没有输入控件。

弹框的标题根据删除目标的不同而动态变化:如果delTarget为’craft’,标题为"取消扎制订单";如果为’course’,标题为"退出风筝课堂"。提示文字也会相应变化,分别说明取消订单与退课的后果。这种动态内容的设计使得一个弹框能够服务于两种删除场景,减少了代码冗余。

底部操作按钮是"取消"与"确认"的组合。取消按钮使用米色背景配灰色文字,确认按钮使用深红色背景配白色粗体文字。深红色的确认按钮传递了"危险操作"的视觉信号,提醒用户谨慎操作。点击确认按钮会调用doDel方法,根据delTarget的值删除对应的项目。

删除确认弹框的设计遵循了"危险操作二次确认"的交互原则——用户在执行不可逆的删除操作前,会被要求再次确认,防止误操作导致的数据丢失。

12.6 bookModalBody:预约场地弹框

bookModalBody构建函数负责渲染预约场地弹框的内容区域。弹框结构与发布弹框类似,包含标题、两个输入框、提示文字与操作按钮。

标题为"预约放飞场地",使用17号粗体深灰色文字。输入区域包含两个TextInput单行输入框:第一个用于输入预约场地,占位符为"预约场地(如 滨河放飞场)“;第二个用于输入预约时段,占位符为"预约时段(如 周六 14:00-18:00)”。

提示文字为"场地免费开放 · 超过 3 米的大型纸鸢需提前报备",使用11号浅灰色文字,宽度100%。这段提示文字既说明了场地的免费政策,又提醒了大型风筝的报备要求,为用户提供了必要的须知信息。

底部操作按钮是"取消"与"提交预约"的组合。提交预约按钮使用橙红色背景配白色粗体文字,与其他弹框的主按钮颜色(天蓝色)不同。橙红色的按钮与"放飞窗口卡"的预约按钮颜色一致,形成了"预约类操作用橙红色"的色彩语义。


十三、bottomBar与mainContent:页面骨架的构建

13.1 bottomBar:底部主导航栏

bottomBar构建函数负责渲染底部主导航栏,它是应用全局导航的核心组件。底部导航栏使用Row布局,内部通过ForEach遍历NAV_LIST数据生成4个导航项。

每个导航项使用Column布局,包含图标emoji与标签文字两部分。图标大小为20像素,选中态不透明度为1(完全不透明),未选中态为0.55(半透明)。标签文字大小为10号,选中态为粗体天蓝色,未选中态为常规字重灰色。导航项通过layoutWeight(1)实现四等分布局,每个导航项的内容都居中对齐。

底部导航栏的整体样式为:宽度100%、高度56像素、白色背景、顶部左右16像素圆角。顶部圆角的设计使得底部导航栏看起来像是一个"浮"在内容上方的托盘,增添了界面的层次感。白色的背景与米灰色的页面背景形成了柔和的对比,确保导航栏始终清晰可见。

导航项的点击事件通过onClick回调处理,点击时将mainTab赋值为当前索引。由于mainTab@State装饰的状态变量,赋值后框架会自动更新UI,重新渲染底部导航的选中态与对应的主内容区。

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

mainContent构建函数负责渲染页面的主内容区,它是整个应用界面的核心骨架。主内容区使用垂直布局(Column),从上到下依次是头部区域、可滚动的内容区域与底部导航栏。

内容区域的渲染逻辑根据mainTab的值进行分支判断:

  • mainTab为0(首页)时,先渲染子导航栏(subNav),再渲染可滚动的内容区。可滚动内容区内部再根据subTab的值渲染对应的子页面(精选、纸鸢橱窗、我的扎制、放飞工坊、风筝课堂、鸢友圈、器材库)。
  • mainTab为1(市集)时,直接渲染纸鸢市集页面(pageMarket)。
  • mainTab为2(课堂)时,直接渲染风筝课堂页面(pageCourse)。
  • mainTab为3(我的)时,直接渲染个人中心页面(pageMine)。

可滚动内容区使用Scroll组件包裹,滚动方向为垂直(ScrollDirection.Vertical),滚动条隐藏(BarState.Off),并通过layoutWeight(1)占据剩余的全部空间。内容区的左右内边距为14像素,底部内边距为20像素,确保内容与屏幕边缘有合适的留白。

这种基于状态变量的条件渲染方式,本质上是一种"状态驱动路由"的实现。与传统的路由框架不同,它不需要额外的路由配置,仅通过状态变量的切换就能实现页面之间的跳转。对于页面数量不多、结构相对简单的应用来说,这种方式既简洁又高效。


十四、build()方法:Stack三层架构的完整画卷

14.1 Stack堆叠布局的三层架构

build()方法是组件的入口函数,它定义了整个页面的UI结构。鸢语应用的build()方法采用了Stack堆叠布局作为根容器,内部包含三层结构,从下到上依次是:背景层、特效层、主内容层与弹框层。

最底层是背景层——一个填满整个屏幕的Column组件,背景色为米灰色(COLORS.bg)。背景层是整个界面的底色,为上层的内容提供了温暖的基调。

第二层是特效层(fxLayer),叠加在背景层之上。特效层包含风筝摇摆与丝带漂移的动态效果,为界面增添了灵动的视觉特色。由于特效层设置了hitTestBehavior(HitTestMode.None),它不会阻挡触摸事件,用户可以穿透特效层与下方的主内容进行交互。

第三层是主内容层——一个Column组件,内部包含mainContent()(主内容区)与bottomBar()(底部导航栏)。主内容层是用户交互的主要区域,占据了整个屏幕的空间。

最上层是弹框层——四个条件渲染的弹框(发布、编辑、删除、预约)。每个弹框都使用Stack布局,内部包含遮罩层(modalOverlay)与弹框体。弹框体宽度为屏幕宽度的88%,最大高度为屏幕高度的80%,圆角为16像素,z-index设为999确保弹框在最上层。

14.2 弹框的条件渲染机制

四个弹框分别由四个布尔状态变量控制:addOpen控制发布鸢语弹框,editOpen控制编辑扎制单弹框,delOpen控制删除确认弹框,bookOpen控制预约场地弹框。

当弹框状态变量为true时,对应的弹框被渲染到Stack的最上层。弹框内部的Stack布局包含两层:底层是半透明的黑色遮罩(modalOverlay),上层是白色的弹框体容器。弹框体容器使用constraintSize({ maxHeight: '80%' })限制最大高度,确保在内容较多时弹框不会超出屏幕范围。

弹框的条件渲染机制是声明式UI的典型应用——开发者只需要声明"当某个状态为真时显示弹框",框架会自动处理组件的添加与移除。这种方式相比命令式的"显示/隐藏"操作更加直观,也更容易维护。

14.3 整体架构的层次思考

从整体架构来看,鸢语应用的build()方法呈现出清晰的四层结构:背景层(最底层)→ 特效层(装饰层)→ 内容层(交互层)→ 弹框层(模态层)。每一层都有明确的职责,层级之间的关系也非常清晰。

这种分层架构有几个显著的优势:一是视觉层次分明,用户能够清晰地感知到界面的深度与空间感;二是职责分离,每一层只负责自己的功能,互不干扰;三是易于扩展,如果需要增加新的层级(如全局加载提示、全局通知等),只需要在对应的层级位置添加即可。

同时,Stack布局作为根容器的选择也非常合理。Stack能够精确地控制元素的堆叠顺序与层级关系,非常适合构建这种多层叠加的界面结构。而ColumnRow则作为各层内部的布局容器,负责具体的元素排列。这种"Stack负责层级、Column/Row负责排列"的布局组合,是ArkUI开发中最常用、也是最高效的布局模式。


十五、应用架构流程图(Mermaid)

下面是鸢语应用的完整架构流程图,展示了从组件入口到各功能模块的完整调用关系与数据流走向:

渲染错误: Mermaid 渲染失败: Parse error on line 58: ...LIST 器材数据] O[@State 状态管理] --> P ---------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'

十六、数据模型对比表

下表对鸢语应用中的四个核心数据模型进行了全面对比,从字段构成、装饰器类型、数据来源、使用场景、渲染方式等多个维度进行了梳理:

对比维度 PostItem(鸢友动态) KiteItem(纸鸢市集) CraftItem(扎制订单) GearItem(器材配件)
装饰器 @Observed @Observed @Observed @Observed
字段数量 6个 6个 5个 4个
主键字段 id: number id: number id: number id: number
核心名称字段 nick(昵称) name(风筝名) name(订单名) name(器材名)
描述字段 text(正文) craft(工艺) item(内容) desc(描述)
特色字段 avatar(emoji头像) level(难度等级) status(状态) icon(emoji图标)
数值字段 likes(点赞数) hot(热度) progress(进度) -
时间字段 time(发布时间) price(价格,string) - -
静态数据源 POST_LIST(8条) KITE_LIST(8款) CRAFT_LIST(8单) GEAR_LIST(8种)
状态存储 postList(@State) -(只读) craftList(@State) -(只读)
列表渲染方式 单列卡片 双列瀑布流/单列列表 单列进度卡片 双列/单列列表
主要页面 精选页、鸢友圈、我的页 精选页、市集页、工坊页 我的扎制页、我的页 工坊页、器材库页
支持新增操作 是(doAdd、doBook)
支持编辑操作 是(doEdit)
支持删除操作 是(doDel退课) 是(doDel取消订单)
颜色编码函数 - kiteTagColor、levelColor statusColor -
键值生成 it.id.toString() it.id.toString() it.id.toString() it.id.toString()
图标类型 emoji头像 emoji图标(🪁/🏮) emoji图标(✂️) emoji图标
社区属性 强(UGC内容) 弱(商品展示) 中(个人订单) 弱(商品展示)

十七、总结:声明式UI与风筝文化的数字化共鸣

17.1 技术架构的整体评价

鸢语·风筝扎制与放飞俱乐部应用是一个技术架构清晰、设计语言统一、交互体验流畅的HarmonyOS ArkUI应用。它以声明式UI开发范式为基础,充分利用了ArkUI框架提供的@Observed状态管理、@Builder组件复用、Stack/Column/Row布局系统等核心能力,构建了一个功能完整、视觉精美的移动端应用。

从代码组织来看,应用采用了"数据-工具-组件"的三层结构。最上层是数据层,包括ColorPalette色彩系统、@Observed数据模型与静态数据数组,它们定义了应用的数据结构与内容基础;中间层是工具函数层,包括图表计算、运动学计算、颜色映射等纯函数,它们为UI渲染提供了计算支持;最下层是组件层,即PageKiteCrafting组件,它将数据与工具整合在一起,构建出完整的用户界面。

这种分层结构使得代码具有良好的可读性与可维护性。数据层的修改不会影响工具层,工具层的优化不会影响组件层,各层之间通过清晰的接口进行通信。开发者在维护代码时,可以快速定位到需要修改的层级,避免了"牵一发而动全身"的问题。

17.2 状态管理的设计智慧

鸢语应用的状态管理设计体现了"分层观察、精准更新"的设计智慧。@State装饰器负责监听组件级别的状态变化(如导航切换、弹框显示、数组替换),@Observed装饰器负责监听对象级别的属性变化(如订单状态修改、动态内容更新)。两者配合使用,实现了从粗粒度到细粒度的全覆盖状态观察。

这种状态管理方案的优势在于:一是精准性高——只有真正发生变化的状态才会触发UI更新,避免了不必要的重新渲染;二是性能优秀——基于属性劫持的观察机制比脏检查更加高效;三是代码简洁——开发者不需要手动调用刷新方法,只需要修改状态变量即可。

同时,应用将业务数据与UI状态分离的设计也值得称道。业务数据(postListcraftList)存储了应用的核心内容,UI状态(mainTabsubTabaddOpen等)控制了界面的显示逻辑。两者各司其职,互不干扰,使得数据逻辑与展示逻辑能够独立演进。

17.3 @Builder复用模式的价值

@Builder装饰器的大量运用是鸢语应用代码的一大特色。整个应用将UI拆解为20余个@Builder函数,每个函数负责一个相对独立的UI模块。这种拆解方式带来了多方面的价值。

首先是代码复用。虽然鸢语应用是单组件结构,但通过@Builder函数,UI片段可以在组件内部的多个位置被复用。例如,纸鸢市集页面(pageMarket)既可以通过首页子导航访问,也可以通过底部市集Tab访问,两者共享同一个@Builder函数。

其次是可读性提升。将一个庞大的build()方法拆解为多个命名清晰的@Builder函数,使得代码结构一目了然。开发者阅读代码时,可以通过函数名快速了解每个模块的功能,而不需要逐行阅读UI描述。

再次是维护成本降低。当需要修改某个UI模块时,只需要修改对应的@Builder函数即可,所有使用该函数的地方都会自动更新。这减少了修改多处相同代码的重复劳动,也降低了遗漏修改的风险。

17.4 特效层的工程意义

fxLayer特效层虽然只是一个视觉装饰,但它的设计与实现却蕴含着深刻的工程思考。特效层的核心挑战在于如何在不影响交互性能的前提下,提供丰富的视觉效果。鸢语应用通过几个关键的设计决策,成功地应对了这一挑战。

一是使用纯代码实现特效,不依赖任何图片或动画资源。这不仅减少了应用的包体积,也避免了图片加载与解码的性能开销。emoji与简单几何形状的组合,虽然看起来简单,但却能够营造出丰富的视觉效果。

二是使用合成属性驱动动画,充分利用GPU的硬件加速能力。translaterotateopacity这些属性的变化不会触发重排与重绘,只需要GPU的合成操作即可完成,因此动画的运行效率非常高。

三是使用hitTestBehavior(HitTestMode.None)实现触摸穿透,确保特效不会干扰用户操作。这个属性的运用是特效层设计的点睛之笔——它使得特效层能够在视觉上叠加在内容之上,同时又在交互层面完全退居幕后。

17.5 色彩系统的品牌构建

鸢语应用的色彩系统是技术与艺术完美结合的典范。天蓝色+橙红色的主辅色搭配,既符合现代UI设计的审美标准,又与风筝文化的天空意象与工艺温度形成了深刻的呼应。丝绸色的引入更是神来之笔——它为数字化的界面注入了传统工艺的人文温度,使得整个应用的品牌调性更加鲜明。

色彩系统的语义化设计同样值得称道。不同的颜色被赋予了明确的语义含义:天蓝色代表主要操作与核心功能,橙红色代表强调与预约,草绿色代表成功与健康,金橙色代表警告与财富,深红色代表危险与删除。用户在使用应用的过程中,会逐渐建立起颜色与语义之间的关联,从而提高操作的效率与准确性。

更重要的是,色彩系统通过ColorPalette接口与COLORS常量实现了集中管理。所有的颜色引用都通过常量访问,而非直接使用十六进制色值。这种集中管理的方式使得颜色的修改与维护变得非常简单——只需要修改常量定义,整个应用的颜色都会同步更新。这对于品牌迭代与主题切换来说,具有重要的工程价值。

17.6 数据驱动的内容生态

鸢语应用构建了一个完整的数据驱动的内容生态。从鸢友动态到纸鸢市集,从扎制订单到器材配件,从课程学习到社区互动,所有的内容都以结构化的数据形式存储,并通过统一的渲染机制展示在界面上。

这种数据驱动的内容架构有几个显著的优势:一是内容与展示分离,数据的修改不会影响UI代码,UI的修改也不会影响数据结构;二是易于扩展,新增内容类型只需要定义新的数据模型与对应的渲染组件即可;三是便于接入后端服务,当前的静态数据可以很方便地替换为从网络请求获取的动态数据,实现应用的服务化升级。

特别值得一提的是应用中丰富的数据可视化设计。柱状图、进度条、饼图式的类别构成图、排行榜等多种数据可视化形式,将抽象的数据转化为直观的视觉呈现,提升了信息传达的效率与用户体验的丰富度。

17.7 对HarmonyOS生态的启示

鸢语应用作为一个基于HarmonyOS ArkTS API 24开发的文创类应用,展示了HarmonyOS生态在应用开发方面的强大能力与独特优势。

声明式UI开发范式使得界面构建变得简洁而直观,开发者可以专注于"描述界面是什么"而非"如何构建界面"。@Observed@State的状态管理组合提供了细粒度、高性能的响应式更新能力,确保了界面的流畅性。@Builder装饰器提供了轻量而灵活的UI复用机制,平衡了复用性与性能。Stack/Column/Row三大基础布局容器的组合,足以应对绝大多数的界面布局需求。

对于HarmonyOS生态来说,鸢语这样的文创类应用具有重要的示范意义。它证明了HarmonyOS不仅能够开发工具类、效率类应用,也能够开发具有丰富视觉效果与文化内涵的文创类应用。随着HarmonyOS生态的不断发展,相信会有更多像鸢语这样的优质应用涌现出来,为用户带来更加丰富多样的使用体验。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 场景:纸鸢橱窗 / 风力放飞 / 扎制工坊 / 鸢友社区
// 底部4主tab(首页/市集/课堂/我的)+ 首页7内容tab(等分顶块式)
// 特效:风筝摇摆 + 丝带漂移(特效层位于底层,不遮挡点击)
// 弹框:新增(发鸢语) / 编辑(编辑扎制单) / 删除(取消扎制订单·退课) / 预约(预约放飞场地)

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

const COLORS: ColorPalette = {
  primary: '#4A6FA5',
  primaryLight: '#E4EAF4',
  primaryDark: '#33517E',
  accent: '#D96C47',
  accentLight: '#F9E6DE',
  bg: '#F4F1EA',
  cardBg: '#FFFFFF',
  textPrimary: '#3A3A45',
  textSecondary: '#6E6E7A',
  textHint: '#ADADB8',
  border: '#E8E4DB',
  success: '#7BA05B',
  warning: '#D9973B',
  danger: '#C0503F',
  white: '#FFFFFF',
  silk: '#B08968'
};

// ============ 鸢友动态数据模型 ============
@Observed
export class PostItem {
  id: number = 0
  nick: string = ''
  avatar: string = ''
  text: string = ''
  time: string = ''
  likes: number = 0
  constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
    this.time = time; this.likes = likes
  }
}

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

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

// ============ 器材数据模型 ============
@Observed
export class GearItem {
  id: number = 0
  name: string = ''
  desc: string = ''
  icon: string = ''
  constructor(id: number, name: string, desc: string, icon: string) {
    this.id = id; this.name = name; this.desc = desc; this.icon = icon
  }
}

// ============ 图表与课程接口 ============
interface WeekChartItem {
  label: string;
  value: number;
}

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

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

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

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '追风老周', '🪁', '三米沙燕上了天,线轮放出去两百米。', '4分钟前', 128),
  new PostItem(2, '竹条手作人', '✂️', '烤竹要顺纹慢弯,急了必裂,切记。', '22分钟前', 96),
  new PostItem(3, '夜放鸢者', '🌙', '带灯串式夜放,回头率百分之百。', '41分钟前', 88),
  new PostItem(4, '鸢语主理人', '🏮', '沙燕复刻赛还有 6 个名额,本周截止。', '1小时前', 82),
  new PostItem(5, '绢面绘制师', '🖌️', '矿物色画脸谱,飞起来颜色稳。', '2小时前', 74),
  new PostItem(6, '滨江常客', '🌬️', '阵风五级别上硬翅,吃过大亏。', '3小时前', 66),
  new PostItem(7, '串式爱好者', '🐉', '十五节蜈蚣串组装完成,求场地。', '5小时前', 58),
  new PostItem(8, '童心未泯', '☀️', '带孩子扎了只小板子,他笑一下午。', '昨天', 52)
];

const KITE_LIST: KiteItem[] = [
  new KiteItem(1, '沙燕 · 黑锅底', '传统硬翅 · 手绘', '¥268/只', '收藏级', 95),
  new KiteItem(2, '蜈蚣串 · 十五节', '龙头串式 · 精扎', '¥880/串', '大师级', 91),
  new KiteItem(3, '蝴蝶 · 软翅对彩', '绢面 · 对染', '¥158/只', '进阶', 86),
  new KiteItem(4, '鹰 · 远看乱真', '板子式 · 竹骨', '¥198/只', '进阶', 82),
  new KiteItem(5, '金鱼 · 双尾摆', '立体桶形 · 充气尾', '¥128/只', '入门', 76),
  new KiteItem(6, '瘦燕 · 三蓝', '传统硬翅 · 深浅三色', '¥388/只', '收藏级', 71),
  new KiteItem(7, '八卦 · 平面板子', '练习板 · 耐摔', '¥48/只', '入门', 64),
  new KiteItem(8, '夜光龙 · 带灯', '串式 · LED 三色', '¥668/串', '高手', 60)
];

const CRAFT_LIST: CraftItem[] = [
  new CraftItem(1, '沙燕 · 胖燕', '三停十二节 · 手绘脸', '已交付', 100),
  new CraftItem(2, '蜈蚣串 · 二十节', '龙头骨架 · 绢面', '扎制中', 74),
  new CraftItem(3, '瘦燕 · 赛事版', '复刻 1932 原款', '裱糊中', 56),
  new CraftItem(4, '鹰 · 悬挂件', '缩小 1:6 · 摆件', '绑扎中', 42),
  new CraftItem(5, '婚庆双燕 · 对', '红金 · 刻字', '绑扎中', 30),
  new CraftItem(6, '夜光小串 · 八节', 'LED · 遥控', '设计稿', 16),
  new CraftItem(7, '社团教学套件 · 20 套', '板子式 · 附教程', '扎制中', 22),
  new CraftItem(8, '展厅主视觉 · 巨型', '4 米沙燕 · 分段', '设计稿', 8)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '竹条 · 三年毛竹', '顺纹易弯', '🎋'),
  new GearItem(2, '绢面 · 熟绢', '透光好上色', '🧵'),
  new GearItem(3, '线轮 · 金属轴承', '放线顺滑', '🎣'),
  new GearItem(4, '乳胶 · 木工级', '绑扎加固', '🧴'),
  new GearItem(5, '风筝线 · 凯夫拉', '高强度不打卷', '🪢'),
  new GearItem(6, '烤竹蜡烛 · 酒精', '弯竹定型', '🕯️'),
  new GearItem(7, '矿物颜料套装', '传统色 12 色', '🎨'),
  new GearItem(8, '放飞绞盘 · 大型', '串式专用', '⚙️')
];

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

const KITE_CHART: KiteChartItem[] = [
  { label: '硬翅类', value: 32, color: '#4A6FA5' },
  { label: '软翅类', value: 24, color: '#D96C47' },
  { label: '板子类', value: 18, color: '#7BA05B' },
  { label: '串式类', value: 14, color: '#D9973B' },
  { label: '立体类', value: 12, color: '#B08968' }
];

const TYPE_LIST: PlayTypeItem[] = [
  { name: '扎制工坊', desc: '烤竹 · 绑扎 · 裱糊', icon: '✂️', hot: true },
  { name: '放飞活动', desc: '风力分级 · 领队带飞', icon: '🪁', hot: true },
  { name: '纸鸢市集', desc: '匠人精扎 · 复刻', icon: '🏮', hot: false },
  { name: '复刻课堂', desc: '老款式 · 考据', icon: '🖌️', hot: false }
];

const COURSE_LIST: CourseItem[] = [
  { title: '竹材挑选与烤弯', week: '第 1 周', progress: 100, color: '#4A6FA5' },
  { title: '绑扎结构与三停法', week: '第 2 周', progress: 86, color: '#D96C47' },
  { title: '绢面裱糊与收边', week: '第 3 周', progress: 58, color: '#7BA05B' },
  { title: '传统纹样与配色', week: '第 4 周', progress: 31, color: '#D9973B' },
  { title: '风力判断与放飞', week: '第 5 周', progress: 8, color: '#B08968' }
];

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

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

const SUB_NAV_LIST: string[] = ['精选', '纸鸢橱窗', '我的扎制', '放飞工坊', '风筝课堂', '鸢友圈', '器材库'];

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

function kiteColor(v: number): string {
  if (v > 10) {
    return '#4A6FA5';
  } else if (v > 6) {
    return '#D96C47';
  }
  return '#D9973B';
}

function kiteA(tick: number, i: number): number {
  return 0.35 + ((tick + i) % 4) * 0.1;
}

function kiteX(tick: number, i: number): number {
  return 60 + ((tick * 6 + i * 211) % 560);
}

function kiteY(tick: number, i: number): number {
  return 70 + ((tick * 3 + i * 149) % 220);
}

function kiteAngle(tick: number, i: number): number {
  return ((tick * 5 + i * 47) % 40) - 20;
}

function ribbonX(tick: number, i: number): number {
  return ((tick * 7 + i * 167) % 640) - 30;
}

function ribbonY(tick: number, i: number): number {
  return 120 + ((tick * 2 + i * 173) % 520);
}

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

function statusColor(s: string): string {
  if (s === '已交付') {
    return '#7BA05B';
  } else if (s === '扎制中') {
    return '#4A6FA5';
  } else if (s === '裱糊中') {
    return '#D96C47';
  } else if (s === '绑扎中') {
    return '#D9973B';
  }
  return '#ADADB8';
}

function statusProgress(s: string): number {
  if (s === '已交付') {
    return 100;
  } else if (s === '扎制中') {
    return 72;
  } else if (s === '裱糊中') {
    return 55;
  } else if (s === '绑扎中') {
    return 38;
  }
  return 10;
}

function kiteTagColor(g: string): string {
  if (g.indexOf('沙燕') >= 0 || g.indexOf('瘦燕') >= 0) {
    return '#4A6FA5';
  } else if (g.indexOf('蜈蚣') >= 0 || g.indexOf('串') >= 0) {
    return '#D96C47';
  } else if (g.indexOf('蝴蝶') >= 0 || g.indexOf('鹰') >= 0) {
    return '#7BA05B';
  }
  return '#D9973B';
}

function levelColor(s: string): string {
  if (s === '入门') {
    return '#7BA05B';
  } else if (s === '进阶') {
    return '#4A6FA5';
  } else if (s === '高手') {
    return '#D9973B';
  } else if (s === '收藏级') {
    return '#D96C47';
  }
  return '#C0503F';
}

@Entry
@Component
struct PageKiteCrafting {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bookOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'craft'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State bookField: string = ''
  @State bookTime: string = ''
  @State postList: PostItem[] = POST_LIST
  @State craftList: CraftItem[] = CRAFT_LIST
  @State fxTimer: number = -1

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

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

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

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

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

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

  openBook(): void {
    this.bookField = '';
    this.bookTime = '';
    this.bookOpen = true;
  }

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

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

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

  doBook(): void {
    if (this.bookField.length > 0) {
      this.postList.unshift(new PostItem(998, '我的鸢语', '🪁', `已预约放飞:${this.bookField} ${this.bookTime}`, '刚刚', 0));
    }
    this.bookOpen = false;
  }

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2], (i: number) => {
        Text('🪁')
          .fontSize(24)
          .rotate({ angle: kiteAngle(this.tick, i) })
          .opacity(kiteA(this.tick, i))
          .translate({ x: kiteX(this.tick, i), y: kiteY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
        Column()
          .width(28)
          .height(3)
          .borderRadius(2)
          .backgroundColor(COLORS.silk)
          .opacity(ribbonA(this.tick, i))
          .rotate({ angle: 14 })
          .translate({ x: ribbonX(this.tick, i), y: ribbonY(this.tick, i) })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Column({ space: 10 }) {
      // 品牌行 + 风况定位
      Row({ space: 10 }) {
        Text('🪁')
          .fontSize(24)
        Column({ space: 2 }) {
          Text('鸢语纸鸢')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('📍 滨河放飞场 · 东南风 3 级 · 宜放硬翅')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('打卡')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor(COLORS.accent)
          .borderRadius(14)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')

      // 放飞窗口卡
      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text('今日放飞窗口 14:00-18:00')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('风力 3 级 · 阵风 5 级 · 硬翅可上 2.4 米')
            .fontSize(10)
            .fontColor('#FBEFE9')
          Row({ space: 6 }) {
            Text('适宜指数')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.silk)
              .borderRadius(6)
            Text('86 分 · 全场可放')
              .fontSize(9)
              .fontColor('#FBEFE9')
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('预约场地')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primaryDark)
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .backgroundColor(COLORS.silk)
          .borderRadius(14)
          .onClick(() => {
            this.openBook();
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.accent)
      .borderRadius(16)

      // 大赛横幅
      Row({ space: 10 }) {
        Text('🏮')
          .fontSize(22)
        Column({ space: 2 }) {
          Text('沙燕复刻赛开启报名')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('老师傅带队三周 · 附送老款图纸一套')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('报名')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor(COLORS.primary)
          .borderRadius(14)
          .onClick(() => {
            this.openBook();
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 数据四格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('收藏纸鸢')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('26 只')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('扎制件数')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('15 件')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('放飞时长')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('208 h')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('鸢币')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('¥72')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ top: 10, bottom: 4 })
  }

  @Builder
  subNav() {
    Scroll() {
      Row() {
        ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
          Column({ space: 4 }) {
            if (this.subTab === idx) {
              Column()
                .width(6)
                .height(6)
                .borderRadius(3)
                .backgroundColor(COLORS.accent)
            } else {
              Column()
                .width(6)
                .height(6)
                .opacity(0)
            }
            Text(item)
              .fontSize(11)
              .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.subTab === idx ? COLORS.primary : COLORS.textSecondary)
              .maxLines(1)
          }
          .layoutWeight(1)
          .padding({ top: 6, bottom: 6 })
          .backgroundColor(this.subTab === idx ? COLORS.primaryLight : COLORS.bg)
          .borderRadius(10)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => {
            this.subTab = idx;
          })
        }, (item: string) => item)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .padding({ top: 6, bottom: 10 })
  }

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

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

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

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

      // 鸢友动态
      Column({ space: 10 }) {
        Row() {
          Text('鸢友动态')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('发鸢语')
            .fontSize(12)
            .fontColor(COLORS.primary)
            .onClick(() => {
              this.openAdd();
            })
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row({ space: 10 }) {
            Text(it.avatar)
              .fontSize(22)
            Column({ space: 3 }) {
              Row() {
                Text(it.nick)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.time)
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
              }
              .width('100%')
              Text(it.text)
                .fontSize(12)
                .fontColor(COLORS.textSecondary)
                .maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(`🪁 ${it.likes}`)
                .fontSize(11)
                .fontColor(COLORS.textHint)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  pageMarket() {
    Column({ space: 10 }) {
      Row() {
        Text('纸鸢橱窗')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('定制我的纸鸢')
          .fontSize(12)
          .fontColor(COLORS.primary)
          .onClick(() => {
            this.openBook();
          })
      }
      .width('100%')
      ForEach(KITE_LIST, (it: KiteItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(kiteTagColor(it.name))
            Column({ space: 4 }) {
              Row() {
                Text(it.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.level)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(levelColor(it.level))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.bg)
                  .borderRadius(6)
              }
              .width('100%')
              Row({ space: 8 }) {
                Text(`🪙 ${it.price}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text(`🌬️ ${it.craft}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
              }
              .width('100%')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${it.hot}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(kiteTagColor(it.name))
          }
          .width('100%')
          Row({ space: 10 }) {
            Text(`关注度 ${it.hot}`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('立即定制')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(COLORS.primary)
              .borderRadius(8)
              .onClick(() => {
                this.openBook();
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: KiteItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageCrafts() {
    Column({ space: 10 }) {
      Text('我的扎制')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(this.craftList, (it: CraftItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Text('✂️')
              .fontSize(24)
            Column({ space: 4 }) {
              Row() {
                Text(it.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.status)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(statusColor(it.status))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.accentLight)
                  .borderRadius(6)
              }
              .width('100%')
              Text(`🧾 ${it.item}`)
                .fontSize(11)
                .fontColor(COLORS.textSecondary)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${statusProgress(it.status)}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(statusColor(it.status))
          }
          .width('100%')
          Row() {
            Text(`扎制进度 ${statusProgress(it.status)}%`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('编辑扎制单')
              .fontSize(11)
              .fontColor(COLORS.primary)
              .onClick(() => {
                this.openEdit(idx);
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: CraftItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageWorkshop() {
    Column({ space: 10 }) {
      Text('放飞工坊')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('当前工位')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('扎制台 W2')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('竹条烘温')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('120℃')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('工序')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('绑扎')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('今日工时')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('4.5 h')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(KITE_LIST.slice(0, 5), (it: KiteItem, idx: number) => {
        Row({ space: 10 }) {
          Text(`${idx + 1}`)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(idx < 3 ? COLORS.primary : COLORS.textHint)
            .width(20)
          Column({ space: 3 }) {
            Text(it.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(`${it.craft} · ${it.price}`)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text(`关注 ${it.hot}`)
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(kiteTagColor(it.name))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: KiteItem) => it.id.toString())
      Text('工具速购')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
        .margin({ top: 4 })
      ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
        if (idx % 2 === 0) {
          Row({ space: 10 }) {
            if (idx < GEAR_LIST.length) {
              Column({ space: 6 }) {
                Text(GEAR_LIST[idx].icon)
                  .fontSize(26)
                Text(GEAR_LIST[idx].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx].desc)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text('加购')
                  .fontSize(11)
                  .fontColor(COLORS.primary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding(12)
              .backgroundColor(COLORS.cardBg)
              .borderRadius(12)
            }
            if (idx + 1 < GEAR_LIST.length) {
              Column({ space: 6 }) {
                Text(GEAR_LIST[idx + 1].icon)
                  .fontSize(26)
                Text(GEAR_LIST[idx + 1].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx + 1].desc)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text('加购')
                  .fontSize(11)
                  .fontColor(COLORS.primary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding(12)
              .backgroundColor(COLORS.cardBg)
              .borderRadius(12)
            }
          }
          .width('100%')
        }
      }, (it: GearItem) => it.id.toString())
    }
    .width('100%')
  }

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

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

  @Builder
  pageGear() {
    Column({ space: 10 }) {
      Text('器材库')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Row({ space: 8 }) {
        Column({ space: 4 }) {
          Text('✂️')
            .fontSize(28)
          Text('扎制区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('竹条 · 绢面')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text('🧵')
            .fontSize(28)
          Text('绑扎区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('骨线 · 乳胶')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text('🎣')
            .fontSize(28)
          Text('放飞区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('线轮 · 绞盘')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(GEAR_LIST, (it: GearItem) => {
        Row({ space: 10 }) {
          Text(it.icon)
            .fontSize(20)
          Column({ space: 3 }) {
            Text(it.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.desc)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text('加购')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.primary)
            .borderRadius(8)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: GearItem) => it.id.toString())
      Button()
        .width('100%')
        .height(42)
        .backgroundColor(COLORS.danger)
        .borderRadius(10)
        .opacity(0.9)
        .onClick(() => {
          this.openDelA();
        })
      Text('取消扎制订单')
        .fontSize(14)
        .fontColor(COLORS.white)
        .margin({ left: -84, top: -32 })
    }
    .width('100%')
  }

  @Builder
  pageMine() {
    Column({ space: 12 }) {
      Row({ space: 12 }) {
        Text('🪁')
          .fontSize(40)
        Column({ space: 4 }) {
          Text('鸢语扎制人')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('白银会员 · 放飞 208 h · 收藏 26 只 · 打卡 76 天')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的扎制档案')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.craftList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.craftList.slice(0, 3), (it: CraftItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.status)
              .fontSize(11)
              .fontColor(statusColor(it.status))
          }
          .width('100%')
        }, (it: CraftItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的鸢语')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.postList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row() {
            Text(it.nick)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.time)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }
    .width('100%')
  }

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

  @Builder
  addModalBody() {
    Column({ space: 12 }) {
      Text('发布鸢语')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '一句话放飞日常…', text: this.addTitle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.addTitle = v;
        })
      TextArea({ placeholder: '补充细节:风况、纸鸢、放飞心得…', text: this.addContent })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(90)
        .onChange((v: string) => {
          this.addContent = v;
        })
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.addOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .onClick(() => {
            this.doAdd();
          })
        Text('发布')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  editModalBody() {
    Column({ space: 12 }) {
      Text('编辑扎制单')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '扎制名称', text: this.editName })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.editName = v;
        })
      TextInput({ placeholder: '状态(如 绑扎中)', text: this.editStyle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.editStyle = v;
        })
      TextArea({ placeholder: '扎制内容与备注说明', text: this.editNote })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(70)
        .onChange((v: string) => {
          this.editNote = v;
        })
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.editOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .onClick(() => {
            this.doEdit();
          })
        Text('保存')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  delModalBody() {
    Column({ space: 12 }) {
      Text(this.delTarget === 'craft' ? '取消扎制订单' : '退出风筝课堂')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(this.delTarget === 'craft' ? '将取消首笔扎制订单并按工序退费,确认执行?' : '将退出首门进行中的课程,确认执行?')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.delOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.danger)
          .borderRadius(8)
          .onClick(() => {
            this.doDel();
          })
        Text('确认')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  bookModalBody() {
    Column({ space: 12 }) {
      Text('预约放飞场地')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '预约场地(如 滨河放飞场)', text: this.bookField })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.bookField = v;
        })
      TextInput({ placeholder: '预约时段(如 周六 14:00-18:00)', text: this.bookTime })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.bookTime = v;
        })
      Text('场地免费开放 · 超过 3 米的大型纸鸢需提前报备')
        .fontSize(11)
        .fontColor(COLORS.textHint)
        .width('100%')
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.bookOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.accent)
          .borderRadius(8)
          .onClick(() => {
            this.doBook();
          })
        Text('提交预约')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -56 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

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

  @Builder
  mainContent() {
    Column() {
      this.header()
      if (this.mainTab === 0) {
        this.subNav()
        Scroll() {
          Column() {
            if (this.subTab === 0) {
              this.pageFeatured()
            } else if (this.subTab === 1) {
              this.pageMarket()
            } else if (this.subTab === 2) {
              this.pageCrafts()
            } else if (this.subTab === 3) {
              this.pageWorkshop()
            } else if (this.subTab === 4) {
              this.pageCourse()
            } else if (this.subTab === 5) {
              this.pageCircle()
            } else {
              this.pageGear()
            }
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else if (this.mainTab === 1) {
        Scroll() {
          Column() {
            this.pageMarket()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else if (this.mainTab === 2) {
        Scroll() {
          Column() {
            this.pageCourse()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else {
        Scroll() {
          Column() {
            this.pageMine()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      }
    }
    .width('100%')
    .height('100%')
  }

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


17.8 结语

在这里插入图片描述

鸢语·风筝扎制与放飞俱乐部应用,以天蓝色与橙红色为主色调,以风筝扎制与放飞为主题,构建了一个集社区、电商、学习、工具于一体的综合性移动应用。它不仅是一个功能完整的应用产品,更是一件精心雕琢的技术艺术品——每一行代码都有其设计考量,每一个颜色都有其语义含义,每一个动效都有其性能优化。

ColorPalette的16色系统到@Observed的四大数据模型,从8组静态数据的内容生态到双层Tab的信息架构,从12个纯函数的计算引擎到20余个@Builder的模块化构建,鸢语应用在技术层面展现了扎实的工程功底与细致的设计思考。它向我们展示了如何在HarmonyOS平台上,用声明式UI的开发范式,构建出一个既美观又高效、既功能丰富又易于维护的移动应用。

Logo

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

更多推荐