引言:声明式UI开发范式与HarmonyOS ArkTS技术体系

在这里插入图片描述

HarmonyOS 6.1.1作为华为分布式操作系统的最新演进版本,其ArkUI声明式UI框架已经发展到了ArkTS API 24阶段,为开发者提供了前所未有的UI构建能力与渲染性能。声明式UI开发范式的核心思想在于"描述UI应该是什么样子,而不是如何一步步构建UI"。在传统的命令式UI开发模式中,开发者需要手动调用创建控件实例、设置控件属性、将控件添加到父容器的子节点列表等一系列繁琐的过程式操作;而在HarmonyOS 6.1.1的ArkUI声明式范式下,开发者只需用一组链式调用的声明式语法描述界面的结构层次和状态绑定关系,框架底层的渲染引擎会自动完成UI组件的创建、更新、复用和销毁。这种从"命令式"到"声明式"的范式转变,极大地提升了开发效率,使得开发者能够将注意力聚焦于业务逻辑和交互体验本身,而非陷入繁琐的控件操作细节。ArkTS语言作为TypeScript的超集,在保留静态类型检查优势的同时,针对HarmonyOS的声明式UI场景做了深度语言层面的优化,其装饰器系统——包括@Entry(入口标记)、@Component(组件声明)、@State(组件内状态)、@Builder(UI构建函数)、@Observed(可观察数据模型)等——构成了声明式UI开发的核心基础设施体系。

HarmonyOS 6.1.1带来的技术进步不仅体现在语言和API层面,更深入到了渲染管线的底层优化机制中。ArkUI渲染管线采用了基于VSync(垂直同步)信号驱动的三阶段渲染机制——“布局测量(Measure)、布局排列(Layout)、绘制合成(Paint & Composite)”,每一帧的渲染都严格遵循16.6毫秒的VSync周期约束。当组件的状态变量发生变更时,ArkUI框架会通过其内置的观察者模式自动触发依赖收集和差分更新流程,只重新渲染真正发生变化的UI节点子树,而非整棵组件树的完整刷新。这种精确到节点级别的差分更新机制,使得即使是在复杂的列表滚动、高频动画特效叠加、弹窗动态显隐等高性能要求的场景下,应用依然能够保持丝滑的60fps流畅体验。在本篇分析的"熔语·灯工玻璃与琉璃手作工坊"应用中,我们可以清晰地看到这一机制的深度运用——每90毫秒触发一次的特效定时器同时驱动着火星飞溅粒子群和热浪光晕呼吸动画两套独立的特效系统,而框架的差分渲染确保了这些高频更新不会影响用户对页面其他内容区域的正常交互操作。

@Observed状态管理机制是HarmonyOS ArkUI声明式UI框架中最核心的数据响应式特性之一。当我们在数据模型类上标注@Observed装饰器时,框架会在运行时为该类的所有可观察属性自动安装变化监听器。一旦这些属性的值发生变更,所有依赖于该属性的UI组件都会收到通知并触发精确的重新渲染。在本应用中,PostItem(熔友动态)、GlassItem(琉璃作品)、WorkItem(烧制订单)、GearItem(工具物料)四个核心业务数据模型均使用了@Observed装饰器进行标注,这意味着当用户发布新的熔语动态、编辑烧制订单状态、取消烧制订单或退出吹制课堂时,界面上所有展示这些数据的组件——动态列表、作品卡片、进度条、状态标签、角标计数——都会自动同步更新,无需开发者手动调用任何刷新方法。这种"数据驱动UI"的编程模型,彻底消除了传统命令式开发中"数据与界面不同步"的顽疾,让状态管理变得自然、可靠且高效。

@Builder装饰器与组件复用机制是ArkUI框架提供的另一个关键架构能力。@Builder装饰器允许开发者将一段UI描述逻辑封装为可复用的构建函数,这些函数可以在组件内部被多次调用,实现UI结构的模块化拆分与组合。在本应用中,整个页面被拆分为近二十个@Builder方法——从fxLayer特效层、header头部、subNav子导航,到pageFeatured(精选)、pageGlasses(作品橱窗)、pageWorks(我的烧制)、pageWorkshop(灯工工坊)、pageCourse(吹制课堂)、pageCircle(熔友圈)、pageGear(物料库)等七个子页面Builder,再到addModalBody(发布熔语弹窗)、editModalBody(编辑烧制单弹窗)、delModalBody(取消烧制/退课弹窗)三个模态弹框组件,以及bottomBar底部导航、mainContent主内容区和modalOverlay遮罩层。这种拆分方式不仅使得代码结构清晰可读,更重要的是让每一个UI模块都可以独立开发、测试和迭代维护。@Builder方法虽然不是独立组件实例,但它们在编译阶段会被内联展开到调用位置,既保证了代码的复用性,又不会引入额外的组件层级开销和渲染性能损耗,是ArkUI框架在"代码复用"与"渲染性能"之间取得的精妙平衡。

Stack/Column/Row布局系统是ArkUI框架提供的三种核心容器组件,它们构成了声明式UI布局的基础原语体系。Stack用于层叠布局,子组件按声明顺序依次堆叠,后声明的子组件覆盖在先声明的子组件之上,非常适合用于实现叠加效果、角标、弹窗遮罩等场景;Column用于垂直线性布局,子组件从上到下依次排列,通过space参数控制间距;Row用于水平线性布局,子组件从左到右依次排列。这三种容器通过layoutWeight(权重分配)、alignItems(交叉轴对齐方式)、justifyContent(主轴对齐方式)等属性的组合配置,可以表达出绝大多数常见的界面布局模式。在本应用的build()方法中,最外层使用Stack实现了四层堆叠结构——背景色层、特效层、内容层、弹窗层,而内容层内部又通过Column+Row的嵌套组合构建出了header头部、窑炉状态卡、体验课横幅、数据四格等复杂的卡片式布局。值得注意的是,应用在特效层fxLayer中使用了hitTestBehavior(HitTestMode.None)来让特效层"穿透"点击事件,使得用户可以透过飞溅的火星粒子和呼吸的热浪光晕直接操作下方的内容层,这一设计充分体现了ArkUI框架对触摸事件分发机制的精细控制能力。

ArkUI渲染管线的高效运作离不开精心设计的色彩系统的支撑。本应用采用了一套以火焰橙(#C56A3B)为主色、青绿(#5A9E9C)为强调色的双主题色彩方案,辅以flame火焰色(#E8955C)作为特殊装饰色,构建出了一种炽热、精致、充满手工艺质感的视觉调性。这种色彩选择与灯工玻璃这一主题的天然属性高度契合——火焰橙直接呼应了喷枪前那道灼热的火焰、退火炉中红热的玻璃熔液以及工匠在高温下专注劳作时金属工具反射出的暖色光芒;青绿色则让人联想到冷却后的琉璃制品、海蓝吊坠在透光时呈现的冷色质感以及玻璃在液态与固态之间转换时的青色过渡;而flame火焰色更是以一种介于橙黄之间的特殊色调,精准地模拟了火焰在高温喷射时的核心焰心色彩。整个色彩系统通过ColorPalette接口定义了16个语义化颜色字段,从primary(主色)到textHint(提示文字色),从success(成功色)到danger(危险色),每一个颜色都有其明确的用途和使用场景,形成了一套完整、自洽、可扩展的设计规范。

灯工玻璃工坊交互模式是本应用在产品体验层面最突出的设计亮点。整个应用采用了"底部4主tab(首页/作品/课堂/我的)+ 首页7内容tab(底部三角指示式)"的双层导航架构。首页的7个内容tab——精选、作品橱窗、我的烧制、灯工工坊、吹制课堂、熔友圈、物料库——通过横向滚动的方式呈现,每个tab项底部叠加了一个8像素见方、旋转45度的菱形三角形指示器,选中时指示器变为flame火焰色并加粗文字,未选中时指示器透明隐藏,模拟了火焰尖端或玻璃滴落的视觉语言,引导用户按内容分区探索。同时,应用还实现了三套完整的弹框交互——发布熔语(addModalBody)、编辑烧制单(editModalBody)、取消烧制订单/退出课堂(delModalBody),每一套弹框都包含了遮罩层、内容卡片、表单输入控件和操作按钮的完整交互链路,并通过@State变量控制显示与隐藏,配合Stack的层叠能力和zIndex层级控制实现了模态弹窗的效果。这种"导航+内容+弹窗"三位一体的交互架构,为灯工玻璃定制与琉璃手作这一垂直领域的用户提供了流畅、直观且功能完整的使用体验。


一、ColorPalette接口设计与火焰橙青绿颜色系统

应用代码的开篇即定义了ColorPalette接口,这是整个应用色彩设计规范的根基。该接口声明了16个字符串类型的颜色字段,每一个字段都承担着明确的视觉语义。这套色彩系统以火焰橙和青绿为双主色调,模拟了灯工玻璃工艺中火焰与琉璃的冷暖色彩关系,为整个手作工坊应用奠定了炽热、精致、充满手工艺质感的视觉基调。

1.1 primary与primaryLight、primaryDark:火焰橙主色三阶体系

在这里插入图片描述

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  ...
}

primary字段被赋值为#C56A3B,这是一个中等明度的火焰橙色。在HSL色彩空间中,它大约对应于H(22°)、S(53%)、L(50%),这种色调介于深橙与赭石之间,带有明显的暖色属性和金属灼热感。这个颜色并非鲜艳的纯橙或亮橙,而是一种接近熔融金属或陶土釉面的沉稳橙色调——饱和度适中、明度不过分张扬,给人温暖、专注、富有工艺感的视觉感受。在应用中,primary被用于窑炉状态卡的主背景色、底部导航栏选中项的文字颜色和图标色、subNav选中项的文字色和背景色、各页面"加入购物车"按钮背景色、周烧制柱状图中高频数据条颜色、"查看窑况"按钮背景色等核心视觉元素。primaryLight被赋值为#F3DCCB,是primary的浅化版本,明度更高、饱和度更低,呈现出接近奶油色与浅杏色之间的一种极浅暖色调,主要用于subNav选中项的浅色背景填充。primaryDark被赋值为#8F4522,是primary的深化版本,明度更低、色调更深沉浓郁,用于深色文字、窑炉状态卡上的"炉温稳定"标签文字色、“查看窑况"按钮文字色等需要与亮色背景形成强对比的场景。这三阶颜色体系构成了一个完整的"主色梯度”,使得同一种火焰橙主题色可以在不同的视觉层级上灵活使用——深色用于前景文字和强交互元素,中色用于品牌标识和主操作按钮,浅色用于背景填充和辅助装饰。

1.2 accent与accentLight:青绿强调色体系

accent: string;
accentLight: string;

accent字段被赋值为#5A9E9C,这是一个中等明度的青绿色。在HSL色彩空间中大约对应于H(178°)、S(28%)、L(49%),带有明显的冷色属性和玻璃质感。这个颜色让人联想到灯工玻璃工艺中冷却后的琉璃制品、海蓝吊坠在透光时呈现的冷色光泽以及玻璃在液态与固态之间转换时那抹独特的青色过渡——它既不是纯青也不是纯绿,而是介于两者之间、带有微妙蓝调的玻璃色。accent作为与primary火焰橙形成冷暖对比的强调色,在应用中被广泛用于二级操作按钮(如体验课横幅的"预约"按钮)、烧制次数数据值颜色、周末烧制占比百分比数值、周烧制柱状图中频数据条颜色、烧制订单状态标签的浅色背景等场景。accentLight被赋值为#DAEDEC,是accent的极浅版本,几乎接近薄荷白但带有一丝清冷的青绿色调,主要用于状态标签的浅色背景——例如烧制订单的状态标签(退火中、烧制中、成型中等)和琉璃作品的等级标签就使用了accentLight作为背景色,与深色文字形成柔和的对比。这种"暖色火焰橙主色+冷色青绿强调"的双色搭配方案,在视觉心理学上能够形成舒适的情绪张力——火焰橙传达温度与工艺,青绿传达玻璃与精致,恰如灯工玻璃本身既是高温熔融的产物又是冷却后精致艺术品的双重本质。

1.3 bg与cardBg:双层背景体系

在这里插入图片描述

bg: string;
cardBg: string;

bg字段被赋值为#FBF2EA,是一个极浅的暖米色,作为整个应用的页面底色使用。这个颜色并非纯白,而是带有微妙的橙色倾向,使得界面背景呈现出一种"工坊木质工作台面"或"陶土窑炉外壁"的质感,与灯工玻璃这一主题的天然属性形成呼应。cardBg被赋值为#FFFFFF(纯白色),作为所有卡片的背景色使用。当白色卡片浮在暖米色页面底色之上时,通过borderRadius圆角和微妙的层级关系,营造出一种"琉璃作品摆放在工坊工作台上"的物理隐喻,这正是Material Design和iOS设计语言中"卡片式设计"的核心表达。bg和cardBg之间约4%的明度差异,既足以让用户视觉上区分背景与内容,又不至于产生割裂感,是经过精心调校的视觉层级设计。这种底色选择也与应用的特效层产生了微妙的协同——当flame火焰色的火星粒子飞溅在暖米色背景之上时,整体画面呈现出一种"工坊中火焰喷溅、热浪升腾"的氛围感。

1.4 textPrimary、textSecondary、textHint:文字三阶体系

textPrimary: string;
textSecondary: string;
textHint: string;

textPrimary被赋值为#3D2E24,是一种深棕褐色,作为所有主要标题和正文文字的颜色。这个颜色并非纯黑,而是带有明显的暖棕色调,与火焰橙主题色在色温上保持了一致性——它让人联想到深色胡桃木或烧焦的松木在高温后呈现的深褐色调,使得文字本身也成为应用视觉调性的一部分。textSecondary被赋值为#7A6A5C,是一种中等明度的灰棕色,用于次要说明文字、副标题、价格信息等不需要强调的内容。textHint被赋值为#B3A294,是一种浅灰棕色,用于最弱层级的提示文字、时间戳、辅助标签等。这三阶文字色形成了一个从深到浅的明度梯度,在视觉上构建出清晰的信息层级关系——标题用textPrimary强调,正文用textSecondary区分,辅助信息用textHint弱化。这种三阶文字体系确保了界面上每一个文字元素都有恰当的视觉权重,既不会让重要信息被淹没,也不会让次要信息喧宾夺主。

1.5 border与white:边框与纯白体系

在这里插入图片描述

border: string;
white: string;

border字段被赋值为#EFE0D2,是一种极浅的暖灰色,作为所有分割线、进度条背景和卡片边框的颜色使用。这个颜色比bg稍深但又比cardBg暖,形成了一种"隐形的分隔"效果——用户能感受到元素之间的边界,但不会因为过于明显的线条而产生视觉干扰。在应用中,border被大量用于进度条的底色(Stack中作为100%宽度的底色条,上面覆盖彩色进度条)、烧制订单进度条背景、作品热度进度条背景等场景。white被赋值为#FFFFFF,与cardBg相同,但语义上用于需要纯白色的特定场景,如按钮文字、角标文字、窑炉状态卡中的辅助文字等。将white和cardBg分离为两个独立字段,虽然当前值相同,但为未来的主题切换和暗色模式适配预留了灵活性——设计师可以独立调整卡片背景和纯白文字的色值,而无需担心相互影响。

1.6 success、warning、danger:语义状态色体系

success: string;
warning: string;
danger: string;

success被赋值为#7BA884,是一种柔和的绿色,用于表示已完成、已发货、成功等正面状态。在应用中,烧制订单状态为"已发货"时使用success色,数据四格中"成品收藏"数值也使用success色,窑炉状态卡中"今日工时"也使用success色,传达出工坊运转顺利、作品完成的积极信号。warning被赋值为#D9A05B,是一种琥珀金黄色,用于表示进行中、待处理、提醒等中性状态。在应用中,烧制状态为"打磨中"时使用warning色,周烧制柱状图中低频数据条使用warning色,数据四格中"熔币"数值也使用warning色,热浪光晕特效中的光晕粒子也使用了warning色作为背景。danger被赋值为#C76B5A,是一种偏红的暖色,用于表示取消、删除、退课等负面或警示性操作。在应用中,购物车角标"2"使用danger色作为背景,"取消烧制"和"退课"按钮使用danger色,作品品类构成中"彩绘灯饰"类别也使用danger色,课程进度条中"退火与冷加工"课程也使用danger色。这三组语义状态色不仅用于状态标签和操作按钮,还被巧妙地融入到图表数据可视化中,使得整个应用的色彩语言形成了统一的语义体系。

1.7 flame:火焰特殊色——点睛之笔

在这里插入图片描述

flame: string;

flame字段被赋值为#E8955C,这是一个介于火焰橙和琥珀黄之间的特殊色调。在HSL色彩空间中大约对应于H(22°)、S(76%)、L(64%),饱和度和明度都高于primary火焰橙,呈现出一种更加明亮、更加炽热的火焰核心色彩。这个颜色是整个色彩系统中的"点睛之笔"——它不承担大面积的背景填充职责,而是在关键的视觉焦点上出现,模拟火焰最灼热处的焰心光芒。在应用中,flame被用于火星飞溅特效中所有火星粒子的背景色(5颗火星粒子全部使用flame色),窑炉状态卡中"炉温稳定"标签的背景色,subNav选中项底部三角形指示器的颜色,烧制状态为"烧制中"时状态标签的文字色等场景。flame的使用频率虽然不如primary和accent高,但每一次出现都精准地锚定在视觉焦点上——飞溅的火星、跳动的火焰指示器、灼热的炉温标签——让整个界面在火焰橙与青绿的冷暖对比中,始终保持着那一抹跳动的"火光"。

1.8 色彩系统全景总结

在这里插入图片描述

const COLORS: ColorPalette = {
  primary: '#C56A3B',
  primaryLight: '#F3DCCB',
  primaryDark: '#8F4522',
  accent: '#5A9E9C',
  accentLight: '#DAEDEC',
  bg: '#FBF2EA',
  cardBg: '#FFFFFF',
  textPrimary: '#3D2E24',
  textSecondary: '#7A6A5C',
  textHint: '#B3A294',
  border: '#EFE0D2',
  success: '#7BA884',
  warning: '#D9A05B',
  danger: '#C76B5A',
  white: '#FFFFFF',
  flame: '#E8955C'
};

整个COLORS常量对象将ColorPalette接口的16个字段赋值为具体的十六进制色值,形成了一套完整的色彩配置表。这套色彩配置在应用启动时即被加载为全局常量,所有@Builder方法和组件内部均通过COLORS.字段名的方式引用颜色值。这种集中式的色彩管理方式带来了三个显著优势:第一,色彩一致性有保障——整个应用中所有"主色"都指向同一个#C56A3B,不会出现不同位置手写色值导致的不一致问题;第二,主题切换可扩展——若未来需要支持暗色模式或节日主题,只需修改COLORS对象即可全局生效;第三,语义清晰可读——代码中COLORS.flame'#E8955C'更具表达力,开发者一眼就能理解该颜色的设计意图。16个颜色字段覆盖了主色三阶(primary/primaryLight/primaryDark)、强调色两阶(accent/accentLight)、双层背景(bg/cardBg)、文字三阶(textPrimary/textSecondary/textHint)、边框与纯白(border/white)、语义状态三色(success/warning/danger)和特殊装饰色(flame),形成了一个维度完整、层次分明的色彩设计系统。


二、@Observed数据模型体系:四类核心业务实体

本应用的数据模型层由四个@Observed装饰的类和四个普通interface构成。@Observed装饰器是ArkUI框架提供的可观察数据模型标记,它使得类的实例属性在发生变化时能够自动通知所有绑定了该属性的UI组件触发重新渲染。这种"数据可观察"的机制是声明式UI数据响应式更新的核心基础。

2.1 PostItem:熔友动态数据模型

在这里插入图片描述

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

PostItem类代表"熔友动态"这一社交内容实体,是工坊社区功能的核心数据载体。该类标注了@Observed装饰器,意味着当动态的点赞数、文本内容等属性发生变化时,界面上的动态列表会自动更新。类中定义了6个实例字段:id(动态唯一标识,数值类型,默认值0)用于列表的key生成和唯一性标识;nick(发布者昵称,字符串类型,默认空串)显示在动态卡片的标题位置;avatar(头像,字符串类型,默认空串)在本应用中使用emoji表情符号作为头像(如"🔥"、“💨”、“🔮"等),这是一种轻量化的头像方案,避免了图片资源加载的开销;text(动态正文,字符串类型,默认空串)是动态的核心内容,在卡片中最多显示2行并支持省略号截断;time(发布时间,字符串类型,默认空串)以相对时间格式展示(如"8分钟前”、“昨天”);likes(点赞数,数值类型,默认值0)以"🔥 124"的格式展示在动态卡片底部。构造函数接收6个参数并依次赋值给实例属性,采用单行简写风格,紧凑而清晰。

2.2 GlassItem:琉璃作品数据模型

在这里插入图片描述

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

GlassItem类代表"琉璃作品"这一商品实体,是作品橱窗和人气榜单功能的核心数据载体。该类同样标注了@Observed装饰器。类中定义了6个实例字段:id(作品唯一标识)、name(作品名称,如"海蓝吊坠 · 水滴形"、“琥珀杯 · 手吹对杯"等,采用"品名 · 修饰语"的命名格式)、craft(工艺描述,如"灯工手工烧制”、“含气泡肌理”、“三层套色"等,传达作品的具体工艺特征)、price(价格信息,字符串类型而非数值类型,因为价格中包含货币符号和单位,如"¥168/件”、“¥298/对”)、level(难度等级,取值为"入门"、“进阶”、“定制”、"大师"四个等级之一,通过levelColor函数映射为不同的颜色)、hot(人气值,数值类型,取值范围0-100,用于热度进度条和人气排序)。值得注意的是price字段使用string类型而非number,这是一个有意的设计决策——因为价格需要携带货币符号(¥)和单位(/件、/对、/束、/瓶、/只),使用字符串可以避免在UI层做格式化拼接,直接展示原始字符串即可。

2.3 WorkItem:烧制订单数据模型

在这里插入图片描述

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

WorkItem类代表"烧制订单"这一业务流程实体,是"我的烧制"功能的核心数据载体,也是应用中唯一支持编辑操作的数据模型。该类定义了5个实例字段:id(订单唯一标识)、name(烧制名称,如"琉璃鲸尾摆件 · 定制"、“婚礼对杯 · 刻名款"等)、item(烧制内容与物料说明,如"渐变蓝白料”、“金边 + 刻字”、“水滴形灯工"等)、status(烧制状态,取值为"已发货”、“退火中”、“烧制中”、“成型中”、“打磨中”、“设计稿”、"待付尾款"等,通过statusColor和statusProgress两个纯函数分别映射为颜色和进度百分比)、progress(烧制进度,数值类型,取值范围0-100,但实际渲染时使用statusProgress函数根据status动态计算,而非直接使用该字段)。这个数据模型是应用中交互最复杂的实体——它支持编辑操作(doEdit方法会splice替换指定索引的订单)、取消操作(doDel方法会splice删除首个订单),编辑弹窗中还会修改name、item、status三个字段。

2.4 GearItem:工具物料数据模型

@Observed
export class GearItem {
  id: number = 0
  name: string = ''
  desc: string = ''
  icon: string = ''
  constructor(id: number, name: string, desc: string, icon: string) {
    this.id = id; this.name = name; this.desc = desc; this.icon = icon
  }
}

GearItem类代表"工具物料"这一器材实体,是物料库和工坊速购功能的核心数据载体。该类定义了4个实例字段:id(物料唯一标识)、name(物料名称,如"钠钙玻璃棒"、“彩玻棒套装”、“喷枪头”、“退火棉"等)、desc(物料描述,简短的规格说明,如"透明 7mm”、“十二色混装”、“灯工专用”、“隔热包裹"等)、icon(物料图标,使用emoji表情符号,如"🕯️”、“🌈”、“🔥”、"🧣"等)。GearItem是四个数据模型中字段最少的一个,但它覆盖了灯工玻璃工艺中从原料(玻璃棒、彩玻棒)到工具(喷枪头、石墨模具、吹管、金刚砂磨盘)到防护(防护墨镜、退火棉)的完整物料链路。该模型虽然不支持增删改操作,但它在物料库页面和灯工工坊速购区域都有展示,是应用中复用度较高的数据实体。

2.5 图表与课程接口:非可观察的数据结构

interface WeekChartItem {
  label: string;
  value: number;
}

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

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

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

除了四个@Observed类之外,应用还定义了四个普通的interface用于图表和课程数据的结构约束。WeekChartItem(周烧制图表项)包含label和value两个字段,用于一周七天的烧制量柱状图数据。GlassChartItem(作品品类图表项)在label和value基础上增加了color字段,用于作品品类构成进度条的颜色标识,每个品类使用不同的主题色。CourseItem(课程项)包含title、week、progress和color四个字段,用于吹制课堂的课程进度展示,每个课程配有独立的颜色标识。CraftTypeItem(工艺类型项)包含name、desc、icon和hot四个字段,其中hot是一个布尔值,标识该工艺类型是否为热门——这个字段的引入使得"灯工定制"和"吹制体验"可以显示"热门"标签,而"作品定制"和"老件修复"则不显示。这四个interface未标注@Observed,因为它们对应的数据是静态的、不会在运行时被修改,使用interface而非class可以避免不必要的可观察性开销。

2.6 数据模型对比表

数据模型装饰器字段数可变性核心场景关键特征字段
PostItem@Observed6可增(unshift)熔友动态/熔友圈likes(点赞数)、avatar(emoji头像)
GlassItem@Observed6只读作品橱窗/人气榜单level(难度等级)、hot(人气值)
WorkItem@Observed5可编辑/可删除我的烧制/我的档案status(状态)、progress(进度)
GearItem@Observed4只读物料库/工坊速购icon(emoji图标)、desc(规格)
WeekChartIteminterface2静态只读周烧制柱状图value(烧制量)
GlassChartIteminterface3静态只读品类构成进度条color(品类色)
CourseIteminterface4静态只读吹制课堂progress(学习进度)、color
CraftTypeIteminterface4静态只读玩法四格hot(是否热门)

三、静态数据数组:工坊全景数据快照

应用在数据模型定义之后,立即声明了一组全局静态常量数组,这些数组构成了应用的初始数据快照。所有数据在应用启动时即被加载到内存中,为各页面的列表渲染提供数据源。

3.1 POST_LIST:熔友动态初始列表

POST_LIST数组包含8条熔友动态数据,每条通过PostItem构造函数创建。动态内容涵盖了灯工玻璃工坊社区中各种典型的用户场景:灯工玻璃匠分享海蓝吊坠的烧制心得(“烧了一颗海蓝吊坠,透光看里面像有浪”)、吹制体验党记录第一次吹杯子的笨拙经历(“第一次吹杯子歪歪扭扭,但越看越喜欢”)、琉璃收藏家展示收到的定制摆件(“收到了定制摆件,气泡分布得刚刚好”)、熔语主理人发布体验课招募信息(“周六灯工体验课还剩4个位置,速来”)、退火耐心人分享等待退火炉开炉的紧张时刻(“退火炉等了8小时,开炉那一刻最紧张”)、彩绘玻璃画师展示铜箔彩绘灯罩成品(“铜箔彩绘灯罩完工,晚上点亮像教堂窗”)、老件修复师记录修复裂痕琉璃杯的过程(“粘好一只裂了的琉璃杯,几乎看不出痕迹”)、工坊开放日主持汇报开放日盛况(“开放日来了35人,排队吹玻璃排到门口”)。这8条动态的点赞数从124到63递减排列,时间从"8分钟前"到"昨天"分布,模拟了一个活跃的工坊社区动态流。POST_LIST在运行时被赋值给@State postList状态变量,用户通过"发熔语"弹框新增的动态会通过unshift方法插入到列表头部。

3.2 GLASS_LIST:琉璃作品初始列表

GLASS_LIST数组包含8件琉璃作品数据,每件通过GlassItem构造函数创建。作品涵盖了灯工玻璃工艺的主要品类:海蓝吊坠(水滴形,入门级,¥168/件,热度96)、琥珀杯手吹对杯(含气泡肌理,进阶级,¥298/对,热度92)、琉璃鲸尾摆件(渐变蓝白料,进阶级,¥428/件,热度88)、定制玻璃花永生花束(可定字缩写,定制级,¥198/束,热度84)、磨砂香薰瓶(配藤条使用,入门级,¥128/瓶,热度78)、灯工小动物猫(可捏姿势,入门级,¥88/只,热度74)、套色纸镇漩涡(三层套色,大师级,¥568/件,热度67)、婚礼对杯刻名款(金边描口,定制级,¥368/对,热度62)。热度值从96到62递减,难度等级覆盖了入门、进阶、定制、大师四个级别,价格从¥88到¥568分布,形成了一个品类丰富、层次分明的作品数据集。GLASS_LIST在应用中被多处复用——作品橱窗页面完整展示8件作品、人气琉璃作品双列展示同样使用GLASS_LIST、灯工工坊人气榜取前5件作品(slice(0,5))——体现了数据的复用设计。

3.3 WORK_LIST:烧制订单初始列表

WORK_LIST数组包含8笔烧制订单数据,每笔通过WorkItem构造函数创建。订单状态覆盖了灯工玻璃烧制的完整工艺流程:已发货(琉璃鲸尾摆件,进度100%)、退火中(婚礼对杯刻名款,进度80%)、烧制中(海蓝吊坠3件,进度62%)、成型中(玻璃花束礼盒装,进度48%)、打磨中(香薰瓶磨砂定制,进度34%)、成型中(灯工猫亲子体验,进度24%)、设计稿(彩绘灯罩定制图,进度14%)、待付尾款(企业礼品杯50只,进度8%)。这8笔订单的进度从100%到8%递减,模拟了一个工坊从设计到发货的完整生产管线。WORK_LIST在运行时被赋值给@State workList状态变量,支持编辑(doEdit方法splice替换)和删除(doDel方法splice删除首项)操作。每笔订单的状态通过statusColor函数映射为颜色,通过statusProgress函数映射为进度百分比,这两个映射关系确保了状态文字与视觉表现的一致性。

3.4 GEAR_LIST:工具物料初始列表

GEAR_LIST数组包含8项工具物料数据,每项通过GearItem构造函数创建。物料覆盖了灯工玻璃工艺所需的完整器材链路:钠钙玻璃棒(透明7mm,原料类)、彩玻棒套装(十二色混装,原料类)、喷枪头(灯工专用,核心工具)、退火棉(隔热包裹,防护类)、石墨模具(杯口整形,成型工具)、防护墨镜(防强光款,安全防护)、吹管(不锈钢1m,吹制核心工具)、金刚砂磨盘(粗细双面,冷加工工具)。每项物料配有emoji图标(🕯️、🌈、🔥、🧣、⚫、🕶️、🥢、⭕),这些图标在物料库页面和工坊速购区域以不同尺寸展示。GEAR_LIST在物料库页面以双列网格布局展示,在灯工工坊页面同样以双列布局展示,是应用中布局复用度最高的数据集之一。

3.5 WEEK_CHART与GLASS_CHART:图表数据

const WEEK_CHART: WeekChartItem[] = [
  { label: '周一', value: 4 },
  { label: '周二', value: 5 },
  ...
  { label: '周日', value: 13 }
];

const GLASS_CHART: GlassChartItem[] = [
  { label: '吊坠饰品', value: 30, color: '#C56A3B' },
  { label: '杯具器皿', value: 26, color: '#5A9E9C' },
  ...
];

WEEK_CHART数组包含一周七天的烧制量数据,数值从周一的4件递增到周六的15件(峰值),周日略降至13件。这个数据分布反映了灯工玻璃工坊在周末(周六、周日)烧制活动最为频繁的运营规律——周末是体验课和定制烧制的高峰期。柱状图中每根柱子的高度通过barH函数计算(Math.round(118 * v / max),max取15),颜色通过glassColor函数根据数值大小映射——大于9使用primary火焰橙、大于6使用accent青绿、其余使用warning琥珀金。GLASS_CHART数组包含5个作品品类的占比数据,每个品类配有独立的颜色:吊坠饰品30%(火焰橙)、杯具器皿26%(青绿)、摆件雕塑20%(琥珀金)、玻璃花艺14%(success绿)、彩绘灯饰10%(danger红),总计100%。这组数据在精选页面的"本月作品品类构成"模块以横向进度条的形式展示。

3.6 TYPE_LIST与COURSE_LIST:玩法与课程数据

const TYPE_LIST: CraftTypeItem[] = [
  { name: '灯工定制', desc: '喷枪前亲手烧制', icon: '🔥', hot: true },
  { name: '吹制体验', desc: '90 分钟带走作品', icon: '💨', hot: true },
  { name: '作品定制', desc: '图纸沟通 · 打样', icon: '🔮', hot: false },
  { name: '老件修复', desc: '粘接 · 补料 · 抛光', icon: '🛠️', hot: false }
];

const COURSE_LIST: CourseItem[] = [
  { title: '玻璃工艺与料性入门', week: '第 1 周', progress: 100, color: '#C56A3B' },
  ...
  { title: '退火与冷加工', week: '第 5 周', progress: 6, color: '#C76B5A' }
];

TYPE_LIST数组定义了4种工坊玩法类型,每种配有名称、描述、图标和热门标识。其中"灯工定制"和"吹制体验"标记为热门(hot: true),在玩法四格中显示"热门"标签(danger红色),而"作品定制"和"老件修复"不显示热门标签。COURSE_LIST数组定义了5周的吹制课堂课程进度数据,从第1周"玻璃工艺与料性入门"(进度100%,火焰橙色)到第5周"退火与冷加工"(进度6%,danger红色),进度依次递减,模拟了一个学员从入门到进阶的学习路径。每门课程配有独立的颜色标识,在课程列表的进度条上使用该颜色,形成视觉上的课程区分。


四、导航配置:双层导航架构

4.1 NAV_LIST:底部4主tab导航

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

const NAV_LIST: NavItem[] = [
  { icon: '🔥', label: '首页' },
  { icon: '🔮', label: '作品' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

NAV_LIST数组定义了应用的底部导航栏配置,包含4个主tab项。每个tab项由icon(emoji图标)和label(中文标签)两个字段组成。4个tab分别为:首页(🔥火焰图标)、作品(🔮水晶球图标)、课堂(📚书本图标)、我的(👤人像图标)。这4个emoji图标与灯工玻璃主题高度相关——火焰代表烧制工艺、水晶球代表琉璃作品、书本代表课程学习、人像代表个人中心。在bottomBar构建函数中,NAV_LIST通过ForEach遍历渲染为4个等宽的导航项,当前选中的tab(由@State mainTab控制)图标完全不透明(opacity:1)且文字使用primary火焰橙色和Bold字重,未选中的tab图标半透明(opacity:0.55)且文字使用textHint浅灰色和Normal字重,形成清晰的选中/未选中视觉对比。

4.2 SUB_NAV_LIST:首页7内容tab(底部三角指示式)

const SUB_NAV_LIST: string[] = ['精选', '作品橱窗', '我的烧制', '灯工工坊', '吹制课堂', '熔友圈', '物料库'];

SUB_NAV_LIST数组定义了首页内部的7个内容分区tab标签。这7个分区覆盖了灯工玻璃工坊应用的全部功能模块:精选(综合内容聚合页)、作品橱窗(琉璃作品展示与购买)、我的烧制(烧制订单跟踪与管理)、灯工工坊(工坊实时状态与物料速购)、吹制课堂(课程学习进度)、熔友圈(社区动态流)、物料库(工具物料浏览与加购)。这7个tab通过横向滚动的方式呈现,每个tab项采用"底部三角指示式"设计——选中项底部叠加一个8像素见方、旋转45度的菱形(通过.rotate({ angle: 45 })实现),指示器使用flame火焰色填充,模拟火焰尖端或玻璃滴落的视觉形态。选中项的文字使用primaryDark深橙色和Bold字重,背景使用primaryLight浅橙色;未选中项的文字使用textSecondary灰棕色和Normal字重,背景使用bg底色,底部指示器透明隐藏(opacity:0占位保持高度一致)。这种底部三角指示式设计是本应用在导航交互上的标志性特征。


五、纯函数工具集:特效计算与状态映射

应用在导航配置之后定义了一组顶层纯函数,这些函数不依赖任何组件状态,仅根据输入参数返回计算结果。它们分为三大类:图表计算函数、特效粒子计算函数和状态颜色映射函数。

5.1 barH:柱状图高度计算

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

barH函数接收当前值v和最大值max两个参数,返回柱状图中单根柱子的像素高度。计算公式为Math.round(118 * v / max),其中118是柱状图区域的最大可用高度(像素),通过将当前值按比例映射到0-118的像素范围内,实现数据的可视化。例如,当max=15(周烧制量最大值)、v=15(周六)时,返回118像素(满高);当v=4(周一)时,返回约31像素。使用Math.round确保返回整数像素值,避免子像素渲染导致的模糊。这个函数的设计简洁高效,是数据可视化中"值到像素"映射的经典实现。

5.2 glassColor:烧制量颜色映射

function glassColor(v: number): string {
  if (v > 9) {
    return '#C56A3B';
  } else if (v > 6) {
    return '#5A9E9C';
  }
  return '#D9A05B';
}

glassColor函数根据烧制量数值v返回对应的颜色字符串。当v大于9时返回primary火焰橙(#C56A3B),表示高烧制量;当v大于6但不超过9时返回accent青绿(#5A9E9C),表示中等烧制量;其余返回warning琥珀金(#D9A05B),表示低烧制量。这种三段式颜色映射使得柱状图不仅展示了数值的高低,还通过颜色编码传达了"繁忙程度"的语义信息——火焰橙代表高峰日、青绿代表平稳日、琥珀金代表低谷日。这个函数在周烧制柱状图中被每根柱子调用,是数据可视化中"值到颜色"语义映射的典型实践。

5.3 sparkA/X/Y/S:火星飞溅粒子计算函数群

function sparkA(tick: number, i: number): number {
  return 0.12 + ((tick + i) % 4) * 0.05;
}

function sparkX(tick: number, i: number): number {
  return 40 + ((tick * 5 + i * 181) % 615);
}

function sparkY(tick: number, i: number): number {
  return 50 + ((tick * 7 + i * 149) % 625);
}

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

这四个函数共同构成了火星飞溅特效的粒子参数计算系统,每个函数接收tick(时间帧计数器)和i(粒子索引)两个参数,返回该粒子在当前帧的某个视觉属性值。

sparkA函数计算火星粒子的透明度(alpha),返回值范围为0.12到0.27。公式0.12 + ((tick + i) % 4) * 0.05中,(tick + i) % 4产生0-3的循环值,乘以0.05后加到基准值0.12上,使得粒子透明度在四个级别间循环跳动,模拟火星闪烁的视觉效果。

sparkX函数计算火星粒子的水平位置(x坐标),返回值范围为40到655。公式40 + ((tick * 5 + i * 181) % 615)中,tick * 5使得每帧x位置偏移5像素,i * 181为每个粒子设置不同的起始偏移量(181是一个质数,确保5个粒子的运动轨迹不会同步),% 615将结果限制在615像素范围内,加上基准值40后x坐标在40-655之间循环。这种使用取模运算实现的位置循环使得火星粒子在水平方向上不断"飞溅-消失-重新出现",模拟了火焰喷射时火星四散的动态效果。

sparkY函数计算火星粒子的垂直位置(y坐标),返回值范围为50到675。公式50 + ((tick * 7 + i * 149) % 625)与sparkX结构类似,但使用tick * 7(每帧y偏移7像素,比x的5像素更快)和i * 149(不同的质数偏移),使得粒子的垂直运动速度与水平运动速度不同,形成斜向飞溅的运动轨迹。

sparkS函数计算火星粒子的尺寸(边长),返回值范围为3到7。公式3 + ((tick + i * 2) % 3) * 2中,(tick + i * 2) % 3产生0-2的循环值,乘以2后加到基准值3上,使得粒子尺寸在3px、5px、7px三个级别间循环变化,模拟火星大小不一的视觉效果。

5.4 heatA/X/Y/R:热浪光晕粒子计算函数群

function heatA(tick: number, i: number): number {
  return 0.04 + ((tick + i) % 5) * 0.02;
}

function heatX(tick: number, i: number): number {
  return 80 + ((tick * 3 + i * 197) % 520);
}

function heatY(tick: number, i: number): number {
  return 90 + ((tick * 5 + i * 173) % 560);
}

function heatR(tick: number, i: number): number {
  return 18 + ((tick + i) % 5) * 8;
}

这四个函数构成了热浪光晕特效的粒子参数计算系统,与火星飞溅函数群结构对称但参数不同,产生更大、更柔、更慢的视觉效果。

heatA函数计算热浪光晕的透明度,返回值范围为0.04到0.12。这个范围远低于火星粒子的0.12-0.27,使得热浪光晕呈现为一种极其微弱的半透明效果,模拟热气在空气中弥漫的柔和视觉。

heatX和heatY函数计算热浪光晕的位置,x范围80-600、y范围90-650。与火星粒子相比,热浪光晕的运动速度更慢(tick * 3tick * 5,小于火星的tick * 5tick * 7),活动范围更小,营造出一种"缓慢漂浮"的热气感。

heatR函数计算热浪光晕的半径,返回值范围为18到50。公式18 + ((tick + i) % 5) * 8中,(tick + i) % 5产生0-4的循环值,乘以8后加到基准值18上,使得光晕半径在18px到50px之间循环变化。在渲染时,光晕的宽度和高度都设为heatR * 2(即直径36-100px),borderRadius设为heatR(即半径18-50px),形成一个正圆形的柔光区域。配合warning琥珀金背景色和极低透明度,模拟了火焰周围空气中热浪扭曲的光晕效果。

5.5 statusColor与statusProgress:烧制状态双映射

function statusColor(s: string): string {
  if (s === '已发货') {
    return '#7BA884';
  } else if (s === '退火中') {
    return '#C56A3B';
  } else if (s === '烧制中') {
    return '#E8955C';
  } else if (s === '成型中') {
    return '#5A9E9C';
  } else if (s === '打磨中') {
    return '#D9A05B';
  }
  return '#B3A294';
}

function statusProgress(s: string): number {
  if (s === '已发货') {
    return 100;
  } else if (s === '退火中') {
    return 80;
  } else if (s === '烧制中') {
    return 62;
  } else if (s === '成型中') {
    return 46;
  } else if (s === '打磨中') {
    return 30;
  }
  return 10;
}

statusColor和statusProgress是一对配对的状态映射函数,它们接收相同的参数(状态字符串s),分别返回该状态对应的颜色和进度百分比。状态到颜色和进度的映射关系如下:已发货→success绿色+100%、退火中→primary火焰橙+80%、烧制中→flame火焰色+62%、成型中→accent青绿+46%、打磨中→warning琥珀金+30%、其他→textHint浅灰棕+10%。

这对函数的设计体现了"状态-颜色-进度"三位一体的映射思想。值得注意的是,“烧制中"状态使用的是flame火焰色(#E8955C)而非primary火焰橙(#C56A3B),这是因为"烧制中"是整个工艺流程中最核心、最灼热的环节,使用更明亮的flame色在视觉上强调其重要性。而"退火中"使用primary火焰橙,因为退火是烧制后的降温过程,温度已有所降低,使用稍暗的primary色而非最亮的flame色,在色彩语义上更为贴切。两个函数的默认返回值(textHint浅灰棕+10%)用于处理"设计稿”、"待付尾款"等未在条件分支中明确列出的状态,确保函数的完备性。

5.6 glassTagColor与levelColor:作品标签颜色映射

function glassTagColor(g: string): string {
  if (g.indexOf('吊坠') >= 0 || g.indexOf('小动物') >= 0) {
    return '#C56A3B';
  } else if (g.indexOf('杯') >= 0 || g.indexOf('瓶') >= 0) {
    return '#5A9E9C';
  } else if (g.indexOf('摆件') >= 0 || g.indexOf('纸镇') >= 0) {
    return '#D9A05B';
  }
  return '#7BA884';
}

function levelColor(s: string): string {
  if (s === '入门') {
    return '#7BA884';
  } else if (s === '进阶') {
    return '#5A9E9C';
  } else if (s === '定制') {
    return '#D9A05B';
  }
  return '#C76B5A';
}

glassTagColor函数通过字符串关键词匹配为琉璃作品分配品类色。当作品名称包含"吊坠"或"小动物"时返回primary火焰橙,包含"杯"或"瓶"时返回accent青绿,包含"摆件"或"纸镇"时返回warning琥珀金,其余返回success绿色。这种基于名称关键词的智能颜色分配使得作品的色条标识(如作品橱窗中每件作品左侧的4px宽色条)能够自动匹配品类,无需在数据模型中额外存储颜色字段。levelColor函数则将难度等级映射为颜色:入门→success绿色、进阶→accent青绿、定制→warning琥珀金、大师→danger红色,形成从绿到红的难度梯度色彩。


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

6.1 @State状态变量声明

@Entry
@Component
struct PageGlassFlamework {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'work'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State workList: WorkItem[] = WORK_LIST
  @State fxTimer: number = -1
  ...
}

应用的主组件PageGlassFlamework声明了16个@State状态变量,这些变量构成了应用全部的响应式状态空间。可以将它们按功能分为五组:

导航状态组:mainTab(当前主tab索引,0-3)控制底部导航的选中状态和主内容区的页面切换;subTab(当前子tab索引,0-6)控制首页内部7个内容分区的切换。

特效状态组:tick(时间帧计数器)是所有特效粒子函数的输入参数,每90毫秒自增1;glow(光晕呼吸计数器)在0和1之间循环切换,用于控制特效的整体呼吸节奏。这两个变量的高频更新驱动着火星飞溅和热浪光晕两套特效系统的持续动画。

弹窗状态组:addOpen(发布熔语弹窗显隐)、editOpen(编辑烧制单弹窗显隐)、delOpen(删除确认弹窗显隐)三个布尔变量分别控制三个模态弹窗的显示与隐藏。

编辑表单状态组:editIdx(当前编辑的订单索引)、editName(编辑中的烧制名称)、editStyle(编辑中的状态)、editNote(编辑中的备注)四个变量用于编辑烧制单弹窗的表单数据双向绑定;addTitle(发布熔语标题)、addContent(发布熔语内容)两个变量用于发布熔语弹窗的表单数据。delTarget(删除目标类型,‘work’或’course’)用于区分取消烧制订单和退出课堂两种删除场景。

数据列表状态组:postList(动态列表,初始值POST_LIST)和workList(订单列表,初始值WORK_LIST)是应用中两个可变的数据集合,分别支持新增(unshift)和编辑/删除(splice)操作。

定时器引用:fxTimer(特效定时器ID,初始值-1)存储setInterval返回的定时器标识,用于在aboutToDisappear时清理定时器。

6.2 生命周期:aboutToAppear与aboutToDisappear

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

aboutToAppear是ArkUI组件的生命周期回调,在组件实例创建后、build()方法首次执行前被调用。在本应用中,aboutToAppear启动了一个90毫秒间隔的setInterval定时器,每90毫秒执行两个操作:tick自增1(驱动火星和热浪粒子的位置/透明度/尺寸变化)和glow在0与1之间切换(驱动特效呼吸节奏)。这个定时器的ID被存储到fxTimer状态变量中,以便后续清理。

aboutToDisappear是组件销毁前的生命周期回调。在本应用中,它检查fxTimer是否大于0(即定时器是否仍在运行),如果是则调用clearInterval清理定时器并将fxTimer重置为-1。这一清理操作至关重要——如果不清理定时器,组件销毁后定时器仍会持续执行,不仅造成内存泄漏,还会因为尝试更新已销毁组件的状态而引发运行时错误。90毫秒的间隔约对应11fps的更新频率,这个频率低于60fps的渲染帧率,但足以让火星飞溅和热浪光晕产生流畅的视觉动画效果,同时避免了更高频率更新带来的性能开销。

6.3 弹窗操作方法群

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

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

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

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

这四个open系列方法是各弹窗的打开入口。openAdd方法在打开发布熔语弹窗前清空表单字段(addTitle和addContent置空),确保每次打开弹窗时表单都是空白的。openEdit方法接收订单索引参数,在打开编辑弹窗前从workList中读取对应订单的数据填充到编辑表单字段中(editName、editStyle、editNote),实现"编辑预填充"的交互模式。openDelA和openDelB分别设置delTarget为’work’和’course’,用于区分"取消烧制订单"和"退出吹制课堂"两种删除场景,delModalBody构建函数会根据delTarget的值显示不同的标题和提示文案。

6.4 数据操作方法群:doAdd、doEdit、doDel

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

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

doAdd方法处理发布熔语操作:当addTitle非空时,使用new PostItem(999, ‘我的熔语’, ‘🔥’, this.addTitle, ‘刚刚’, 0)创建一条新动态并通过unshift方法插入到postList头部。新动态的id固定为999(在实际应用中应使用自增ID或UUID),昵称为"我的熔语",头像为🔥火焰图标,时间为"刚刚",初始点赞数为0。插入完成后关闭弹窗。

doEdit方法处理编辑烧制单操作:使用splice方法的替换模式splice(editIdx, 1, newItem)将指定索引的订单替换为新的WorkItem实例。新订单保留了原订单的id,但name、item、status字段使用编辑表单中的值,progress固定为40。替换完成后关闭弹窗。

doDel方法处理删除操作:当delTarget为’work’时,从workList头部删除第一个订单(splice(0,1));当delTarget为’course’时,从postList头部删除第一条动态。删除完成后关闭弹窗。值得注意的是,当delTarget为’course’时,实际删除的是postList(动态列表)而非COURSE_LIST(课程列表),这是因为COURSE_LIST是静态常量数组无法直接修改,应用通过删除动态列表来模拟"退出课堂后移除相关动态"的交互效果。


七、fxLayer特效层:火星飞溅与热浪光晕

@Builder
fxLayer() {
  Stack({ alignContent: Alignment.TopStart }) {
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(sparkS(this.tick, i))
        .height(sparkS(this.tick, i))
        .borderRadius(sparkS(this.tick, i) / 2)
        .backgroundColor(COLORS.flame)
        .opacity(sparkA(this.tick, i))
        .translate({ x: sparkX(this.tick, i), y: sparkY(this.tick, i) })
    }, (i: number) => i.toString())
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(heatR(this.tick, i) * 2)
        .height(heatR(this.tick, i) * 2)
        .borderRadius(heatR(this.tick, i))
        .backgroundColor(COLORS.warning)
        .opacity(heatA(this.tick, i))
        .translate({ x: heatX(this.tick, i), y: heatY(this.tick, i) })
    }, (i: number) => i.toString())
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

fxLayer构建函数是应用视觉特效的核心实现,它通过一个Stack容器层叠了两组ForEach渲染的粒子群——5颗火星粒子和5颗热浪光晕,共10个动态粒子。

火星粒子群(第一组ForEach):使用[0,1,2,3,4]数组生成5颗火星粒子,每颗粒子都是一个Column组件,通过以下属性链配置视觉效果:width和height都设为sparkS(this.tick, i)(3-7px的动态尺寸),使得火星呈正方形;borderRadius设为sparkS/2,将正方形变为圆形(因为borderRadius等于边长一半时形成正圆);backgroundColor设为COLORS.flame(#E8955C火焰色),让火星呈现明亮的橙黄色光芒;opacity设为sparkA(this.tick, i)(0.12-0.27的低透明度),使火星呈现半透明的闪烁效果;translate通过sparkX和sparkY设置粒子的位移坐标,使火星在40-655(x)和50-675(y)的范围内飞溅移动。

热浪光晕群(第二组ForEach):同样使用[0,1,2,3,4]数组生成5颗热浪光晕,每颗光晕的width和height都设为heatR*2(36-100px的较大尺寸),borderRadius设为heatR(等于半径,形成正圆),backgroundColor设为COLORS.warning(#D9A05B琥珀金色),opacity设为heatA(this.tick, i)(0.04-0.12的极低透明度),translate通过heatX和heatY设置位移。与火星粒子相比,热浪光晕的尺寸更大(36-100px vs 3-7px)、透明度更低(0.04-0.12 vs 0.12-0.27)、运动速度更慢,营造出一种"大而柔、慢而淡"的热气弥漫效果,与"小而亮、快而闪"的火星飞溅形成互补的视觉层次。

**hitTestBehavior(HitTestMode.None)**是fxLayer最关键的设计决策。HitTestMode.None使得整个特效层完全不参与触摸事件的命中测试,所有触摸事件会"穿透"特效层直达下方的内容层。这意味着用户可以透过飞溅的火星和漂浮的热浪直接点击按钮、滚动列表、切换tab,特效层的存在不会对用户的交互操作产生任何干扰。这一设计充分体现了ArkUI框架对触摸事件分发链的精细控制能力,也是"视觉层"与"交互层"分离设计思想的优秀实践。

整个fxLayer以Stack({ alignContent: Alignment.TopStart })作为容器,宽高均设为100%,覆盖整个屏幕区域。特效层在build()方法中被放置在背景色层之上、内容层之下,形成"背景→特效→内容→弹窗"的四层堆叠结构。


八、header头部构建:窑炉状态与数据概览

header构建函数是应用首页的顶部固定区域,它通过Column容器垂直排列了四个功能模块:搜索条与购物车、窑炉状态卡、体验课横幅、数据四格。这个区域在首页始终可见,为用户提供工坊的核心状态概览和快捷操作入口。

8.1 搜索条与购物车角标

Row({ space: 10 }) {
  Text('🔥')
    .fontSize(24)
  Row({ space: 6 }) {
    Text('🔍')
      .fontSize(12)
    Text('搜一搜「吊坠」「吹制体验」')
      .fontSize(11)
      .fontColor(COLORS.textHint)
      .layoutWeight(1)
  }
  .layoutWeight(1)
  .padding({ left: 12, right: 12, top: 8, bottom: 8 })
  .backgroundColor(COLORS.cardBg)
  .borderRadius(18)
  Stack() {
    Text('🧺')
      .fontSize(20)
    Text('2')
      .fontSize(8)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .padding({ left: 4, right: 4, top: 1, bottom: 1 })
      .backgroundColor(COLORS.danger)
      .borderRadius(8)
      .translate({ x: 10, y: -10 })
  }
  .width(30)
  .height(30)
}

搜索条区域通过Row水平排列三个元素:左侧的🔥火焰logo(24px字号)、中间的搜索框(白色背景、18px圆角的胶囊形,内含🔍放大镜图标和灰色提示文字"搜一搜「吊坠」「吹制体验」“,通过layoutWeight(1)占据剩余空间)、右侧的购物车图标。购物车图标使用Stack堆叠了🧺篮子图标和数字角标"2”,角标使用danger红色背景、white白色文字、8px圆角,通过translate({ x: 10, y: -10 })偏移到购物车图标的右上角,形成经典的"未读角标"视觉效果。这种搜索条+购物车的设计是电商类应用的标准头部布局,在本应用中被适配为灯工玻璃工坊的语境。

8.2 窑炉状态卡

Row({ space: 12 }) {
  Column({ space: 4 }) {
    Text('窑炉 1080℃ · 正在烧制')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
    Text('婚礼对杯批次退火中 · 明早开炉')
      .fontSize(10)
      .fontColor('#F5E0CE')
    Row({ space: 6 }) {
      Text('炉温稳定')
        ...
        .backgroundColor(COLORS.flame)
      Text('今日开炉 2 次')
        ...
    }
  }
  .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.flame)
    .borderRadius(14)
    .onClick(() => {
      this.openEdit(1);
    })
}
.padding(14)
.backgroundColor(COLORS.primary)
.borderRadius(16)

窑炉状态卡是header中最醒目的视觉元素,它使用primary火焰橙(#C56A3B)作为整卡背景色,营造出"窑炉正在燃烧"的炽热氛围。卡片左侧通过Column垂直排列三行信息:主标题"窑炉 1080℃ · 正在烧制"(15px白色粗体,展示当前炉温和烧制状态)、副标题"婚礼对杯批次退火中 · 明早开炉"(10px浅暖色#F5E0CE,展示当前批次和开炉计划)、标签行("炉温稳定"使用flame火焰色背景+primaryDark深色文字的标签,"今日开炉2次"为浅色文字)。卡片右侧是"查看窑况"按钮,使用flame火焰色背景和primaryDark深色文字,14px圆角胶囊形,点击后调用this.openEdit(1)打开编辑烧制单弹窗(索引1对应"婚礼对杯·刻名款"订单)。这个设计将窑炉实时状态展示与烧制订单编辑操作巧妙地关联在一起,用户查看窑况的同时可以直接编辑对应的烧制单。

8.3 体验课横幅

体验课横幅使用cardBg白色背景,与上方的火焰橙窑炉状态卡形成明暗对比。横幅左侧是💨吹气图标,中间是"灯工体验90分钟·随到随烧"的标题和"带走一件吊坠·团购第二人半价"的副标题,右侧是"预约"按钮(accent青绿色背景、white白色文字、14px圆角)。点击"预约"按钮调用this.openAdd()打开发布熔语弹窗,将"预约体验课"的意图转化为"发布一条熔语动态"的操作流。这种将不同功能模块通过弹窗操作串联起来的设计,体现了应用"以动态为核心"的社交化产品思路。

8.4 数据四格

数据四格通过Row水平排列四个等宽的Column,每个Column展示一个关键数据指标:在途作品3件(primary火焰橙色数值)、烧制次数47次(accent青绿色数值)、成品收藏16件(success绿色数值)、熔币¥70(warning琥珀金色数值)。每个格子使用cardBg白色背景、10px圆角、8px内边距,标题使用9px textHint浅灰色,数值使用13px Bold粗体对应颜色。这四个数据指标从不同维度量化了用户在工坊中的活动成果——在途作品反映当前进行中的烧制量、烧制次数反映历史工作量、成品收藏反映作品积累、熔币反映虚拟资产,形成了一个完整的用户数据画像。


九、subNav子导航:底部三角指示式设计

@Builder
subNav() {
  Scroll() {
    Row({ space: 12 }) {
      ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
        Column({ space: 3 }) {
          Text(item)
            .fontSize(this.subTab === idx ? 13 : 11)
            .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.subTab === idx ? COLORS.primaryDark : COLORS.textSecondary)
          if (this.subTab === idx) {
            Column()
              .width(8)
              .height(8)
              .backgroundColor(COLORS.flame)
              .rotate({ angle: 45 })
          } else {
            Column()
              .width(8)
              .height(8)
              .opacity(0)
          }
        }
        .padding({ left: 4, right: 4, top: 6, bottom: 2 })
        .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 })
}

subNav构建函数是首页内部7个内容分区的导航控件,它采用了"底部三角指示式"的独特设计语言。整个子导航通过Scroll容器实现横向滚动(scrollable(ScrollDirection.Horizontal)),滚动条被隐藏(scrollBar(BarState.Off)),确保视觉简洁。

每个tab项是一个Column容器,垂直排列文字标签和底部指示器。文字标签的字号在选中时为13px、未选中时为11px,字重在选中时为Bold、未选中时为Normal,文字颜色在选中时为primaryDark深橙色、未选中时为textSecondary灰棕色,形成了选中/未选中的三重视觉区分。

底部三角指示器是subNav最核心的设计特征。当tab被选中时(subTab === idx),指示器是一个8x8像素的Column,使用flame火焰色(#E8955C)填充,通过.rotate({ angle: 45 })旋转45度,将正方形变为菱形——从视觉上看就是一个尖端朝下的三角形,模拟了火焰尖端的形态。当tab未被选中时,指示器同样是一个8x8像素的Column,但opacity设为0(完全透明),通过"占位但不可见"的方式保持选中与未选中项的高度一致,避免布局跳动。每个tab项的背景色在选中时为primaryLight浅橙色、未选中时为bg底色,10px圆角,进一步强化选中状态。

点击任意tab项时,onClick回调将subTab设置为对应索引,触发ArkUI框架的差分更新,自动重新渲染subNav和mainContent中的页面内容。这种"点击即切换"的即时响应配合flame火焰色菱形指示器的视觉锚定,为用户提供了清晰的位置感知和流畅的导航体验。


十、各页面Builder:七大内容分区详解

10.1 pageFeatured:精选页

pageFeatured是首页默认展示的精选内容聚合页,通过Column({ space: 12 })垂直排列四个内容模块:玩法四格、周烧制柱状图、作品品类构成、人气琉璃作品双列、熔友动态。

玩法四格模块通过Row水平排列TYPE_LIST的4个CraftTypeItem,每格展示图标(24px emoji)、名称(11px粗体)、描述(9px灰色,单行省略截断)和热门标签(hot为true时显示danger红色的"热门"标签)。这四格分别对应灯工定制、吹制体验、作品定制、老件修复四种工坊玩法,让用户一目了然地了解工坊的核心服务。

周烧制柱状图模块使用WEEK_CHART数据渲染7根柱子,每根柱子通过Column垂直排列柱体(20px宽,高度由barH函数计算)、数值标签和星期标签。柱体颜色由glassColor函数根据数值映射。柱状图整体高度140px,底部对齐排列,右上方标注"周末烧制占比60%"(accent青绿色14px粗体)。这个柱状图直观地展示了工坊一周的烧制量分布,周末(周六15件、周日13件)明显高于工作日。

作品品类构成模块遍历GLASS_CHART数据,为每个品类渲染一行横向进度条——左侧品类名称(70px宽)、中间Stack层叠的底色条(border色,100%宽)和进度条(品类色,value%宽)、右侧百分比数值。5个品类从吊坠饰品30%到彩绘灯饰10%依次排列,每个品类使用不同颜色,形成多彩的数据可视化效果。

人气琉璃作品双列模块通过巧妙的ForEach+条件判断实现双列网格布局。遍历GLASS_LIST时,当idx为偶数时渲染一行双列(idx和idx+1两件作品),每件作品卡片包含图标、等级标签、名称和价格热度信息。这种"偶数索引起始、同行渲染两件"的方式避免了使用Grid组件的复杂性,用Row+Column的简单组合实现了双列效果。

熔友动态模块取postList前3条(slice(0,3))展示,每条动态通过Row水平排列头像(22px emoji)和动态内容Column(昵称、时间、正文、点赞数)。右上角"发熔语"文字按钮点击后调用openAdd()打开发布弹窗。

10.2 pageGlasses:作品橱窗页

pageGlasses页面完整展示GLASS_LIST的8件琉璃作品,采用单列列表布局。每件作品卡片通过Column垂直排列三部分:作品信息行(左侧4px宽glassTagColor色条+作品名称和等级标签+价格和工艺信息)、热度进度条(Stack层叠的底色条和glassTagColor色进度条,宽度为hot%)、操作行(人气值文字+"加入购物车"按钮)。作品左侧的4px色条通过glassTagColor函数根据作品名称关键词自动匹配颜色,为每件作品提供了品类视觉标识。"加入购物车"按钮使用primary火焰橙背景,是作品橱窗页的核心转化入口。

10.3 pageWorks:我的烧制页

pageWorks页面展示workList中的烧制订单,每笔订单通过Column垂直排列:订单信息行(🔥图标+订单名称+状态标签,状态标签使用statusColor色文字和accentLight浅色背景)、进度条(Stack层叠,进度宽度由statusProgress函数计算,颜色由statusColor函数映射)、操作行(进度百分比文字+"编辑烧制单"文字按钮,点击调用openEdit(idx)打开编辑弹窗)。这个页面是应用中交互最密集的区域——每笔订单都可以点击"编辑烧制单"进入编辑流程,而物料库页面的"取消烧制"按钮则会删除workList的首项。

10.4 pageWorkshop:灯工工坊页

pageWorkshop页面展示工坊的实时状态和物料速购功能,通过Column垂直排列三个模块:工位状态四格(当前工位"灯工台E1"、喷枪温度"1250℃"、工序"塑形"、今日工时"6.0h")、人气榜单(GLASS_LIST前5件作品的单列排名,前三名使用primary火焰橙色排名数字)、物料速购双列(GEAR_LIST的双列网格展示)。工位状态四格的四个数值分别使用primary、accent、warning、success四种颜色,与数据四格的设计思路一致——通过颜色编码区分不同维度的信息。人气榜单中前三名的排名数字使用primary火焰橙色,后两名使用textHint浅灰色,形成"热门前三"的视觉强调。

10.5 pageCourse:吹制课堂页

pageCourse页面展示COURSE_LIST的5门课程进度,每门课程通过Column垂直排列:标题行(课程名称+周次标签)、进度条(Stack层叠,使用课程自带的color字段着色)、进度信息行("已学"标签+百分比数值)。页面底部有一个danger红色的"退课"按钮,点击后调用openDelB()打开删除确认弹窗(delTarget设为’course’)。按钮的实现采用了Button组件+Text组件叠加的特殊方式——Button作为可点击的背景层,Text通过负margin偏移叠加在Button上方显示"退课"文字,这是一种在不使用Button内容属性的情况下实现文字按钮的变通方案。

10.6 pageCircle:熔友圈页

pageCircle页面完整展示postList中的所有动态(不截断),每条动态通过Row水平排列头像(26px emoji)和动态内容Column(昵称+时间、正文、点赞和回复信息)。与精选页的熔友动态模块相比,pageCircle展示了完整的动态流(而非前3条),且每条动态底部增加了"💬 回复"文字,提供了更完整的社交互动信息。右上角同样有"发熔语"入口。

10.7 pageGear:物料库页

pageGear页面展示GEAR_LIST的8项工具物料,通过Column垂直排列三个模块:物料分区三格(玻料区🕯️、工具区🔥、防护区🕶️)、物料列表(8项物料的单列展示,每项通过Row水平排列图标、名称描述和"加购"按钮)、底部"取消烧制"按钮(danger红色,点击调用openDelA()打开删除确认弹窗,delTarget设为’work’)。物料列表中每项的"加购"按钮使用primary火焰橙背景、white白色文字、8px圆角,是物料加购的核心操作入口。

10.8 pageMine:我的页

pageMine页面是"我的"tab的内容页,通过Column垂直排列三个模块:用户信息卡(🔥头像+"熔语手作人"昵称+琥珀会员信息行)、我的烧制档案(workList前3条订单的简要展示)、我的熔语(postList前3条动态的简要展示)。用户信息卡中"琥珀会员·烧制47次·收藏16件·打卡79天"的信息行串联了数据四格中的数据指标,形成用户画像的完整描述。烧制档案和熔语模块的标题行分别显示${this.workList.length}单${this.postList.length}条的动态计数,当用户新增动态或编辑/删除订单时,这些计数会自动更新,体现了@State响应式状态的数据驱动特性。


十一、弹框组件:三套模态交互

11.1 modalOverlay:遮罩层

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

modalOverlay是三个弹窗共用的遮罩层组件,它通过一个全屏的Column组件实现60%不透明度的黑色遮罩(#000000, opacity:0.6)。遮罩层的onClick回调同时将addOpen、editOpen、delOpen三个弹窗状态变量全部设为false,实现"点击遮罩层关闭任意弹窗"的统一交互。这种"遮罩层+内容卡片"的分离设计使得三个弹窗可以共享同一个遮罩层组件,避免了代码重复。

11.2 addModalBody:发布熔语弹窗

addModalBody是发布熔语弹窗的内容卡片,通过Column垂直排列:标题"发布熔语"、TextInput单行输入框(绑定addTitle,placeholder"一句话工坊日常…“)、TextArea多行输入框(绑定addContent,placeholder"补充细节:作品、炉温、烧制心得…”)、操作按钮行("取消"按钮关闭弹窗+"发布"按钮调用doAdd())。两个输入框都使用bg底色背景、8px圆角,通过onChange回调将输入值同步到@State变量。按钮行采用与pageCourse退课按钮相同的Button+Text叠加方案——两个Button组件通过layoutWeight(1)等分宽度,Text通过负margin偏移叠加显示文字。

11.3 editModalBody:编辑烧制单弹窗

editModalBody是编辑烧制单弹窗的内容卡片,结构与addModalBody类似但包含三个输入字段:TextInput"烧制名称"(绑定editName)、TextInput"状态(如退火中)“(绑定editStyle)、TextArea"烧制内容与备注说明”(绑定editNote)。三个输入框在弹窗打开时已通过openEdit方法预填充了对应订单的数据,用户可以在预填充内容的基础上进行修改。操作按钮行的"保存"按钮调用doEdit()方法,将修改后的数据通过splice替换写回workList。

11.4 delModalBody:删除确认弹窗

delModalBody是删除确认弹窗的内容卡片,它根据delTarget的值动态显示不同的标题和提示文案。当delTarget为’work’时,标题为"取消烧制订单",提示文案为"将取消首笔烧制订单并按工序退费,确认执行?“;当delTarget为’course’时,标题为"退出吹制课堂”,提示文案为"将退出首门进行中的课程,确认执行?"。操作按钮行中"确认"按钮使用danger红色背景,点击后调用doDel()执行删除操作。这种"一个弹窗组件+条件渲染文案"的设计减少了弹窗组件的数量,通过delTarget参数实现了两种删除场景的统一处理。


十二、bottomBar底部导航与mainContent主内容区

12.1 bottomBar:底部4主tab导航

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

bottomBar通过Row容器水平排列NAV_LIST的4个tab项,每个tab项通过layoutWeight(1)等分宽度。每个tab项是Column容器,垂直排列emoji图标(20px)和中文标签(10px)。选中项的图标完全不透明(opacity:1)、标签使用primary火焰橙色和Bold粗体;未选中项的图标半透明(opacity:0.55)、标签使用textHint浅灰色和Normal字重。整个底部栏使用cardBg白色背景,顶部两角16px圆角(borderRadius({ topLeft: 16, topRight: 16 })),形成悬浮在内容上方的导航条效果。高度固定56px,点击任意tab项将mainTab设为对应索引,触发mainContent的页面切换。

12.2 mainContent:主内容区路由

@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.pageGlasses()
          } else if (this.subTab === 2) {
            this.pageWorks()
          } 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() { ... this.pageGlasses() ... }
    } else if (this.mainTab === 2) {
      Scroll() { ... this.pageCourse() ... }
    } else {
      Scroll() { ... this.pageMine() ... }
    }
  }
  .width('100%')
  .height('100%')
}

mainContent是应用的内容路由核心,它通过mainTab和subTab两个状态变量的if-else条件判断链实现页面切换。当mainTab为0(首页)时,先渲染header头部和subNav子导航,再通过Scroll容器包裹一个Column,Column内部根据subTab的值(0-6)渲染对应的7个页面Builder之一。当mainTab为1(作品)时直接渲染pageGlasses,为2(课堂)时渲染pageCourse,为3(我的)时渲染pageMine。所有页面内容都通过Scroll容器实现垂直滚动(scrollable(ScrollDirection.Vertical)),滚动条隐藏,通过layoutWeight(1)占据header/subNav以下、bottomBar以上的剩余空间,左右14px内边距、底部20px内边距确保内容不贴边。

这种双层if-else条件渲染的路由设计虽然不如Navigation组件的页面栈管理优雅,但在单页面应用中足够简洁高效。每次mainTab或subTab变化时,ArkUI框架会自动销毁旧页面Builder生成的UI节点树并创建新页面的节点树,由于@State postList和workList是跨页面共享的,数据在页面切换时不会丢失。


十三、build()方法:四层堆叠架构

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.editModalBody() ... }
    }
    if (this.delOpen) {
      Stack() { ... this.delModalBody() ... }
    }
  }
  .width('100%')
  .height('100%')
}

build()方法是整个应用的渲染入口,它通过一个最外层Stack容器实现了四层堆叠架构:

第一层(最底)——背景色层:一个全屏的Column组件,backgroundColor设为COLORS.bg(#FBF2EA暖米色),作为整个应用的底色。

第二层——特效层:调用this.fxLayer()渲染火星飞溅和热浪光晕特效粒子群。特效层使用hitTestBehavior(HitTestMode.None)穿透触摸事件,不干扰用户交互。

第三层——内容层:一个全屏的Column容器,垂直排列mainContent()主内容区和bottomBar()底部导航。这是用户可交互的主要内容区域,包含header、subNav、各页面内容和底部导航栏。

第四层(最顶)——弹窗层:三个if条件渲染的Stack堆叠弹窗。当addOpen为true时渲染发布熔语弹窗,editOpen为true时渲染编辑烧制单弹窗,delOpen为true时渲染删除确认弹窗。每个弹窗Stack内部堆叠了modalOverlay()遮罩层和内容卡片Column,内容卡片宽度88%、最大高度80%、16px圆角、zIndex(999)确保浮于所有内容之上。

这种四层堆叠架构通过Stack的层叠能力和if条件渲染的动态显隐,实现了"背景→特效→内容→弹窗"的清晰视觉层级。特效层始终存在但不干扰交互,弹窗层按需出现并自动覆盖内容层,背景色层提供统一的底色基调。整个build()方法结构清晰、层次分明,充分体现了ArkUI声明式UI"描述结构而非操作过程"的设计哲学。


十四、应用架构流程图

0 首页

1 作品

2 课堂

3 我的

0 精选

1 作品橱窗

2 我的烧制

3 灯工工坊

4 吹制课堂

5 熔友圈

6 物料库

true

true

true

每90ms更新tick和glow

驱动特效动画

应用启动

aboutToAppear生命周期

启动90ms特效定时器

build方法渲染

Stack四层堆叠

第一层: 背景色层

第二层: fxLayer特效层

第三层: 内容层

第四层: 弹窗层

火星飞溅粒子群 x5

热浪光晕粒子群 x5

sparkA/X/Y/S函数计算

heatA/X/Y/R函数计算

header头部

mainContent主内容区

bottomBar底部导航

搜索条+购物车

窑炉状态卡

体验课横幅

数据四格

mainTab判断

subNav子导航

pageGlasses

pageCourse

pageMine

subTab判断

pageFeatured

pageWorks

pageWorkshop

pageCircle

pageGear

addOpen?

editOpen?

delOpen?

addModalBody发布熔语

editModalBody编辑烧制单

delModalBody删除确认

doAdd: postList.unshift

doEdit: workList.splice替换

doDel: workList/postList.splice删除

NAV_LIST 4主tab

首页🔥

作品🔮

课堂📚

我的👤


十五、技术总结与设计哲学反思

15.1 声明式UI范式的工程价值

通过对"熔语·灯工玻璃与琉璃手作工坊"应用的深度技术分析,我们可以清晰地看到HarmonyOS ArkTS API 24声明式UI开发范式在工程实践中的巨大价值。声明式UI将"UI描述"与"状态管理"彻底解耦——开发者只需用@State声明状态变量、用@Builder描述UI结构、用@Observed标记数据模型,框架的渲染引擎会自动完成状态追踪、依赖收集、差分更新和DOM同步的全部工作。在本应用中,16个@State变量和4个@Observed数据模型类构成了完整的响应式状态空间,当任何一个状态变量或可观察属性发生变化时,框架会自动且精确地更新所有受影响的UI节点,无需开发者手动调用任何invalidate、notifyDataSetChanged或setProps等方法。这种"数据驱动UI"的编程模型不仅极大地简化了状态管理的复杂度,更从根本上消除了传统命令式UI中"状态与界面不一致"这一最常见、最棘手的bug类型。

15.2 @Builder模块化拆分的架构优势

应用将近二十个@Builder方法用于UI结构的模块化拆分,这种设计在架构层面带来了三重优势。第一是可读性——每个@Builder方法对应一个明确的UI模块(fxLayer特效、header头部、subNav导航、7个页面Builder、3个弹窗Builder、bottomBar导航、mainContent路由),方法名即模块名,代码结构一目了然。第二是复用性——modalOverlay遮罩层被三个弹窗共用,pageGlasses在首页subTab=1和主tab=1两处复用,GLASS_LIST数据在pageFeatured人气双列、pageGlasses作品橱窗、pageWorkshop人气榜单三处复用,@Builder的内联展开机制确保了复用不引入额外的组件层级开销。第三是可维护性——每个@Builder方法都可以独立开发、调试和修改,例如调整header的窑炉状态卡只需修改header方法内部的代码,不会影响其他模块。这种"高内聚、低耦合"的模块化设计是大型UI应用可维护性的基石。

15.3 特效系统的性能与交互平衡

fxLayer特效层的设计是本应用在"视觉表现力"与"交互流畅度"之间取得平衡的典范。火星飞溅和热浪光晕两套特效共10个粒子,每90毫秒更新一次位置、透明度和尺寸参数,通过translate位移而非left/top属性变更来实现位置更新(translate走合成层transform,不触发布局重排,性能更优)。更关键的是hitTestBehavior(HitTestMode.None)的运用——特效层完全不参与触摸事件的命中测试,所有触摸事件直达下方的内容层,确保用户可以透过飞溅的火星和漂浮的热浪流畅地操作界面。这种"视觉层与交互层分离"的设计思想,使得应用在不牺牲任何交互性能的前提下,实现了丰富的动态视觉效果。同时,aboutToDisappear生命周期中对定时器的清理操作,避免了组件销毁后的内存泄漏和无效更新,体现了对资源生命周期的严谨管理。

15.4 色彩系统的设计系统思维

ColorPalette接口和COLORS常量构成的16字段色彩系统,体现了成熟的设计系统思维。火焰橙(#C56A3B)和青绿(#5A9E9C)的冷暖双色搭配,不仅与灯工玻璃"火焰+琉璃"的主题高度契合,更在视觉心理学上形成了温度与精致的情感张力。三阶主色(primary/primaryLight/primaryDark)、三阶文字(textPrimary/textSecondary/textHint)、三色状态(success/warning/danger)和特殊装饰色(flame)的分层设计,确保了每一种视觉需求都有对应的语义化颜色字段。纯函数群(glassColor、statusColor、statusProgress、glassTagColor、levelColor)将状态和品类映射为颜色,实现了"数据到颜色"的自动化映射,避免了硬编码色值的不一致性。这种集中管理、语义命名、函数映射的色彩系统设计,使得应用的视觉一致性得到了根本保障,也为未来的主题切换和暗色模式适配预留了充分的扩展空间。

15.5 双层导航与弹窗交互的产品架构

"底部4主tab+首页7内容tab(底部三角指示式)"的双层导航架构,在有限的屏幕空间内承载了丰富的功能模块。底部三角指示式的subNav设计是本应用在导航交互上的标志性创新——flame火焰色的45度旋转菱形指示器模拟了火焰尖端的形态,与灯工玻璃主题形成了视觉语义上的呼应。三套弹窗(发布熔语、编辑烧制单、删除确认)通过modalOverlay遮罩层共用、if条件渲染动态显隐、zIndex层级控制实现了完整的模态交互链路。doAdd/doEdit/doDel三个数据操作方法通过unshift新增、splice替换、splice删除分别处理三种数据变更场景,操作完成后自动关闭弹窗,形成了"打开弹窗→填写表单→确认操作→关闭弹窗→数据更新→UI刷新"的完整交互闭环。这种"导航路由+条件渲染弹窗+状态驱动数据操作"的架构模式,为垂直领域的功能型应用提供了清晰、可靠且可扩展的交互框架范本。

15.6 数据模型设计的领域驱动特征

四个@Observed数据模型(PostItem、GlassItem、WorkItem、GearItem)和四个interface(WeekChartItem、GlassChartItem、CourseItem、CraftTypeItem)的设计体现了领域驱动设计(DDD)的思想。每个数据模型都对应灯工玻璃工坊业务领域中的一个核心实体——动态、作品、订单、物料——字段定义精准反映业务属性。@Observed装饰器仅标注在需要运行时可变的模型上(PostItem支持新增、WorkItem支持编辑和删除),而静态数据(图表、课程、玩法类型)使用普通interface避免不必要的可观察性开销。WorkItem的status字段通过statusColor和statusProgress两个配对函数实现"状态到颜色"和"状态到进度"的双重映射,GlassItem的level字段通过levelColor函数映射颜色,GlassItem的name字段通过glassTagColor函数的关键词匹配映射品类色——这种"数据字段+纯函数映射"的设计模式,将业务逻辑(状态→颜色、等级→颜色、品类→颜色)封装在可测试的纯函数中,而非散落在UI代码里,提升了代码的可维护性和可测试性。


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

const COLORS: ColorPalette = {
  primary: '#C56A3B',
  primaryLight: '#F3DCCB',
  primaryDark: '#8F4522',
  accent: '#5A9E9C',
  accentLight: '#DAEDEC',
  bg: '#FBF2EA',
  cardBg: '#FFFFFF',
  textPrimary: '#3D2E24',
  textSecondary: '#7A6A5C',
  textHint: '#B3A294',
  border: '#EFE0D2',
  success: '#7BA884',
  warning: '#D9A05B',
  danger: '#C76B5A',
  white: '#FFFFFF',
  flame: '#E8955C'
};

// ============ 熔友动态数据模型 ============
@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 GlassItem {
  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 WorkItem {
  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 GlassChartItem {
  label: string;
  value: number;
  color: string;
}

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

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

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '灯工玻璃匠', '🔥', '烧了一颗海蓝吊坠,透光看里面像有浪。', '8分钟前', 124),
  new PostItem(2, '吹制体验党', '💨', '第一次吹杯子歪歪扭扭,但越看越喜欢。', '23分钟前', 103),
  new PostItem(3, '琉璃收藏家', '🔮', '收到了定制摆件,气泡分布得刚刚好。', '41分钟前', 95),
  new PostItem(4, '熔语主理人', '🏺', '周六灯工体验课还剩 4 个位置,速来。', '1小时前', 89),
  new PostItem(5, '退火耐心人', '⏳', '退火炉等了 8 小时,开炉那一刻最紧张。', '2小时前', 76),
  new PostItem(6, '彩绘玻璃画师', '🎨', '铜箔彩绘灯罩完工,晚上点亮像教堂窗。', '3小时前', 70),
  new PostItem(7, '老件修复师', '🛠️', '粘好一只裂了的琉璃杯,几乎看不出痕迹。', '5小时前', 63),
  new PostItem(8, '工坊开放日主持', '🏛️', '开放日来了 35 人,排队吹玻璃排到门口。', '昨天', 74)
];

const GLASS_LIST: GlassItem[] = [
  new GlassItem(1, '海蓝吊坠 · 水滴形', '灯工手工烧制', '¥168/件', '入门', 96),
  new GlassItem(2, '琥珀杯 · 手吹对杯', '含气泡肌理', '¥298/对', '进阶', 92),
  new GlassItem(3, '琉璃摆件 · 鲸尾', '渐变蓝白料', '¥428/件', '进阶', 88),
  new GlassItem(4, '定制玻璃花 · 永生', '可定字缩写', '¥198/束', '定制', 84),
  new GlassItem(5, '香薰瓶 · 磨砂款', '配藤条使用', '¥128/瓶', '入门', 78),
  new GlassItem(6, '灯工小动物 · 猫', '可捏姿势', '¥88/只', '入门', 74),
  new GlassItem(7, '套色纸镇 · 漩涡', '三层套色', '¥568/件', '大师', 67),
  new GlassItem(8, '婚礼对杯 · 刻名', '金边描口', '¥368/对', '定制', 62)
];

const WORK_LIST: WorkItem[] = [
  new WorkItem(1, '琉璃鲸尾摆件 · 定制', '渐变蓝白料', '已发货', 100),
  new WorkItem(2, '婚礼对杯 · 刻名款', '金边 + 刻字', '退火中', 80),
  new WorkItem(3, '海蓝吊坠 · 3 件', '水滴形灯工', '烧制中', 62),
  new WorkItem(4, '玻璃花束 · 礼盒装', '永生花 9 支', '成型中', 48),
  new WorkItem(5, '香薰瓶 · 磨砂定制', '刻字母款', '打磨中', 34),
  new WorkItem(6, '灯工猫 · 亲子体验', '孩子亲手做', '成型中', 24),
  new WorkItem(7, '彩绘灯罩 · 定制图', '铜箔 Tiffany', '设计稿', 14),
  new WorkItem(8, '企业礼品杯 · 50 只', '批量吹制', '待付尾款', 8)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '钠钙玻璃棒', '透明 7mm', '🕯️'),
  new GearItem(2, '彩玻棒套装', '十二色混装', '🌈'),
  new GearItem(3, '喷枪头', '灯工专用', '🔥'),
  new GearItem(4, '退火棉', '隔热包裹', '🧣'),
  new GearItem(5, '石墨模具', '杯口整形', '⚫'),
  new GearItem(6, '防护墨镜', '防强光款', '🕶️'),
  new GearItem(7, '吹管', '不锈钢 1m', '🥢'),
  new GearItem(8, '金刚砂磨盘', '粗细双面', '⭕')
];

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

const GLASS_CHART: GlassChartItem[] = [
  { label: '吊坠饰品', value: 30, color: '#C56A3B' },
  { label: '杯具器皿', value: 26, color: '#5A9E9C' },
  { label: '摆件雕塑', value: 20, color: '#D9A05B' },
  { label: '玻璃花艺', value: 14, color: '#7BA884' },
  { label: '彩绘灯饰', value: 10, color: '#C76B5A' }
];

const TYPE_LIST: CraftTypeItem[] = [
  { name: '灯工定制', desc: '喷枪前亲手烧制', icon: '🔥', hot: true },
  { name: '吹制体验', desc: '90 分钟带走作品', icon: '💨', hot: true },
  { name: '作品定制', desc: '图纸沟通 · 打样', icon: '🔮', hot: false },
  { name: '老件修复', desc: '粘接 · 补料 · 抛光', icon: '🛠️', hot: false }
];

const COURSE_LIST: CourseItem[] = [
  { title: '玻璃工艺与料性入门', week: '第 1 周', progress: 100, color: '#C56A3B' },
  { title: '灯工基础手法', week: '第 2 周', progress: 86, color: '#5A9E9C' },
  { title: '吊坠与珠子实操', week: '第 3 周', progress: 56, color: '#D9A05B' },
  { title: '小件吹制训练', week: '第 4 周', progress: 28, color: '#7BA884' },
  { title: '退火与冷加工', week: '第 5 周', progress: 6, color: '#C76B5A' }
];

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

const NAV_LIST: NavItem[] = [
  { icon: '🔥', label: '首页' },
  { icon: '🔮', label: '作品' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '作品橱窗', '我的烧制', '灯工工坊', '吹制课堂', '熔友圈', '物料库'];

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

function glassColor(v: number): string {
  if (v > 9) {
    return '#C56A3B';
  } else if (v > 6) {
    return '#5A9E9C';
  }
  return '#D9A05B';
}

function sparkA(tick: number, i: number): number {
  return 0.12 + ((tick + i) % 4) * 0.05;
}

function sparkX(tick: number, i: number): number {
  return 40 + ((tick * 5 + i * 181) % 615);
}

function sparkY(tick: number, i: number): number {
  return 50 + ((tick * 7 + i * 149) % 625);
}

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

function heatA(tick: number, i: number): number {
  return 0.04 + ((tick + i) % 5) * 0.02;
}

function heatX(tick: number, i: number): number {
  return 80 + ((tick * 3 + i * 197) % 520);
}

function heatY(tick: number, i: number): number {
  return 90 + ((tick * 5 + i * 173) % 560);
}

function heatR(tick: number, i: number): number {
  return 18 + ((tick + i) % 5) * 8;
}

function statusColor(s: string): string {
  if (s === '已发货') {
    return '#7BA884';
  } else if (s === '退火中') {
    return '#C56A3B';
  } else if (s === '烧制中') {
    return '#E8955C';
  } else if (s === '成型中') {
    return '#5A9E9C';
  } else if (s === '打磨中') {
    return '#D9A05B';
  }
  return '#B3A294';
}

function statusProgress(s: string): number {
  if (s === '已发货') {
    return 100;
  } else if (s === '退火中') {
    return 80;
  } else if (s === '烧制中') {
    return 62;
  } else if (s === '成型中') {
    return 46;
  } else if (s === '打磨中') {
    return 30;
  }
  return 10;
}

function glassTagColor(g: string): string {
  if (g.indexOf('吊坠') >= 0 || g.indexOf('小动物') >= 0) {
    return '#C56A3B';
  } else if (g.indexOf('杯') >= 0 || g.indexOf('瓶') >= 0) {
    return '#5A9E9C';
  } else if (g.indexOf('摆件') >= 0 || g.indexOf('纸镇') >= 0) {
    return '#D9A05B';
  }
  return '#7BA884';
}

function levelColor(s: string): string {
  if (s === '入门') {
    return '#7BA884';
  } else if (s === '进阶') {
    return '#5A9E9C';
  } else if (s === '定制') {
    return '#D9A05B';
  }
  return '#C76B5A';
}

@Entry
@Component
struct PageGlassFlamework {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'work'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State workList: WorkItem[] = WORK_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.workList.length) {
      this.editIdx = index;
      this.editName = this.workList[index].name;
      this.editStyle = this.workList[index].status;
      this.editNote = this.workList[index].item;
    }
    this.editOpen = true;
  }

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

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

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

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

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

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column()
          .width(sparkS(this.tick, i))
          .height(sparkS(this.tick, i))
          .borderRadius(sparkS(this.tick, i) / 2)
          .backgroundColor(COLORS.flame)
          .opacity(sparkA(this.tick, i))
          .translate({ x: sparkX(this.tick, i), y: sparkY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column()
          .width(heatR(this.tick, i) * 2)
          .height(heatR(this.tick, i) * 2)
          .borderRadius(heatR(this.tick, i))
          .backgroundColor(COLORS.warning)
          .opacity(heatA(this.tick, i))
          .translate({ x: heatX(this.tick, i), y: heatY(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)
        Row({ space: 6 }) {
          Text('🔍')
            .fontSize(12)
          Text('搜一搜「吊坠」「吹制体验」')
            .fontSize(11)
            .fontColor(COLORS.textHint)
            .layoutWeight(1)
        }
        .layoutWeight(1)
        .padding({ left: 12, right: 12, top: 8, bottom: 8 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        Stack() {
          Text('🧺')
            .fontSize(20)
          Text('2')
            .fontSize(8)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 4, right: 4, top: 1, bottom: 1 })
            .backgroundColor(COLORS.danger)
            .borderRadius(8)
            .translate({ x: 10, y: -10 })
        }
        .width(30)
        .height(30)
      }
      .width('100%')

      // 窑炉状态卡
      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text('窑炉 1080℃ · 正在烧制')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('婚礼对杯批次退火中 · 明早开炉')
            .fontSize(10)
            .fontColor('#F5E0CE')
          Row({ space: 6 }) {
            Text('炉温稳定')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.flame)
              .borderRadius(6)
            Text('今日开炉 2 次')
              .fontSize(9)
              .fontColor('#F5E0CE')
          }
        }
        .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.flame)
          .borderRadius(14)
          .onClick(() => {
            this.openEdit(1);
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.primary)
      .borderRadius(16)

      // 体验课横幅
      Row({ space: 10 }) {
        Text('💨')
          .fontSize(22)
        Column({ space: 2 }) {
          Text('灯工体验 90 分钟 · 随到随烧')
            .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.accent)
          .borderRadius(14)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 数据四格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('在途作品')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('3 件')
            .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('47 次')
            .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('16 件')
            .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('¥70')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ top: 10, bottom: 4 })
  }

  @Builder
  subNav() {
    Scroll() {
      Row({ space: 12 }) {
        ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
          Column({ space: 3 }) {
            Text(item)
              .fontSize(this.subTab === idx ? 13 : 11)
              .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.subTab === idx ? COLORS.primaryDark : COLORS.textSecondary)
            if (this.subTab === idx) {
              Column()
                .width(8)
                .height(8)
                .backgroundColor(COLORS.flame)
                .rotate({ angle: 45 })
            } else {
              Column()
                .width(8)
                .height(8)
                .opacity(0)
            }
          }
          .padding({ left: 4, right: 4, top: 6, bottom: 2 })
          .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: CraftTypeItem) => {
            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: CraftTypeItem) => 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, 15))
                .borderRadius(5)
                .backgroundColor(glassColor(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('60%')
            .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(GLASS_CHART, (it: GlassChartItem) => {
          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: GlassChartItem) => 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(GLASS_LIST, (it: GlassItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < GLASS_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🔮')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(GLASS_LIST[idx].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(GLASS_LIST[idx].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(GLASS_LIST[idx].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${GLASS_LIST[idx].price} · 热度 ${GLASS_LIST[idx].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < GLASS_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🕯️')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(GLASS_LIST[idx + 1].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(GLASS_LIST[idx + 1].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(GLASS_LIST[idx + 1].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${GLASS_LIST[idx + 1].price} · 热度 ${GLASS_LIST[idx + 1].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: GlassItem) => 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
  pageGlasses() {
    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(GLASS_LIST, (it: GlassItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(glassTagColor(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(glassTagColor(it.name))
          }
          .width('100%')
          Row({ space: 10 }) {
            Text(`人气值 ${it.hot}`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('加入购物车')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(COLORS.primary)
              .borderRadius(8)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: GlassItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageWorks() {
    Column({ space: 10 }) {
      Text('我的烧制')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(this.workList, (it: WorkItem, 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: WorkItem) => 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('灯工台 E1')
            .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('1250℃')
            .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('6.0 h')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(GLASS_LIST.slice(0, 5), (it: GlassItem, 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(glassTagColor(it.name))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: GlassItem) => 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: -56, 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('琥珀会员 · 烧制 47 次 · 收藏 16 件 · 打卡 79 天')
            .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.workList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.workList.slice(0, 3), (it: WorkItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.status)
              .fontSize(11)
              .fontColor(statusColor(it.status))
          }
          .width('100%')
        }, (it: WorkItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的熔语')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.postList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row() {
            Text(it.nick)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.time)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }
    .width('100%')
  }

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

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

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

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

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

  @Builder
  mainContent() {
    Column() {
      this.header()
      if (this.mainTab === 0) {
        this.subNav()
        Scroll() {
          Column() {
            if (this.subTab === 0) {
              this.pageFeatured()
            } else if (this.subTab === 1) {
              this.pageGlasses()
            } else if (this.subTab === 2) {
              this.pageWorks()
            } 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.pageGlasses()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else if (this.mainTab === 2) {
        Scroll() {
          Column() {
            this.pageCourse()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else {
        Scroll() {
          Column() {
            this.pageMine()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      }
    }
    .width('100%')
    .height('100%')
  }

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


15.7 总结

在这里插入图片描述

基于HarmonyOS ArkTS API 24声明式UI框架,通过@Observed状态管理、@Builder模块化拆分、Stack/Column/Row三原语布局系统、火焰橙青绿双主题色彩系统、火星飞溅热浪光晕特效层、底部三角指示式双层导航、三套模态弹窗交互等技术和设计的综合运用,构建了一个功能完整、视觉精致、交互流畅的灯工玻璃垂直领域应用。应用的代码结构清晰、数据模型精准、色彩系统自洽、特效设计巧妙、交互架构完整,充分展现了HarmonyOS 6.1.1 ArkUI声明式UI开发范式在垂直领域应用开发中的技术优势和工程价值。无论是从UI架构设计的角度,还是从产品交互体验的角度,本应用都为同类垂直领域应用的ArkTS开发提供了具有参考价值的实践范本。

Logo

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

更多推荐