一、技术背景与开发理念

在这里插入图片描述

HarmonyOS 6.1.1作为华为全场景分布式操作系统的最新演进版本,在开发者体验与UI渲染能力上实现了质的飞跃。基于HarmonyOS ArkTS API 24,开发者能够利用更加完善的声明式UI范式构建出具备高性能、高一致性、高可维护性的跨设备应用。ArkTS语言在TypeScript的基础上进行了静态类型强化和编译期优化,使得运行时性能大幅提升,同时保留了JavaScript生态的灵活性和开发效率。在HarmonyOS 6.1.1的环境下,ArkTS不仅继承了强类型语言的安全性优势,还通过@Entry、@Component、@State、@Observed等装饰器体系,为开发者提供了一套完整的响应式编程模型,使得UI与数据状态之间的绑定变得自然而然。

声明式UI的核心思想在于"状态驱动视图"——开发者只需描述界面在任何给定状态下的外观,而框架会自动处理状态变化时的UI更新。这一理念在HarmonyOS ArkTS API 24中得到了充分体现。通过@State装饰器声明的变量,一旦发生改变,框架便会自动触发关联的UI组件重新渲染,无需手动操作DOM或调用invalidate方法。这种模式不仅减少了样板代码,还从根本上杜绝了状态与视图不一致的常见Bug。在晶语手作工坊这样一个包含多层级导航、复杂列表渲染、实时动效粒子和多种弹窗交互的应用场景中,声明式UI的优势尤为突出。

UV滴胶与热缩片手作工坊是一个极具垂直行业特征的应用场景。在这个场景中,用户既是一个手作爱好者,也是一个微型的创业者——他们需要管理自己的作品进度、展示成品橱窗、参与工坊课程、与晶友社区互动,还需要接受定制订单。这种多元化的业务需求对应用架构提出了很高的要求:页面结构必须层次分明,数据模型必须能够精确映射业务实体,交互流程必须覆盖增删改查的完整闭环。基于HarmonyOS API 24的ArkTS开发模式,通过@Component结构化的组件拆分和@Builder装饰器的UI片段复用机制,为这类复杂业务应用提供了清晰的架构骨架。

从技术架构的角度来看,本应用采用了经典的"单页面多Tab+模态弹窗"架构。底部4个主Tab(首页、橱窗、课堂、我的)构成了应用的一级导航,而首页内部又通过7个内容子Tab(精选、成品橱窗、我的作品、固化工坊、滴胶课堂、晶友圈、材料库)实现了二级内容分发。这种双层Tab架构在HarmonyOS应用中非常常见,它能够在有限的屏幕空间内承载丰富的功能模块,同时保持导航路径的清晰性。配合setInterval驱动的粒子动效系统和条件渲染的模态弹窗系统,整个应用在视觉表现力和交互完整性上都达到了较高的水准。

此外,本应用在色彩系统设计上也值得深入探讨。紫色(#7B6CAE)与青色(#5FB8A0)的双色调搭配,不仅在视觉上呼应了UV滴胶饰品特有的梦幻感与晶莹感,还在功能层面承担了信息层级的标识作用。主色调用于品牌标识、选中状态和主操作按钮,辅色调用于进度指示、状态标签和次要操作按钮,粉色调(#F2B8C6)则作为点缀色用于特殊状态和动效粒子。这种经过精心设计的色彩系统,使得应用在保持视觉统一性的同时,能够通过颜色快速传达业务语义,是HarmonyOS应用设计中的优秀实践。

二、色彩系统与接口定义分析

在这里插入图片描述

2.1 ColorPalette接口与COLORS常量

应用的色彩系统首先通过一个TypeScript接口进行类型约束,然后以常量对象的形式实例化。这种"接口定义+常量实例化"的模式在ArkTS开发中是一种最佳实践,它既保证了类型安全,又提供了统一的颜色管理入口。

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;
  glitter: string;
}

const COLORS: ColorPalette = {
  primary: '#7B6CAE',
  primaryLight: '#EEEBF6',
  primaryDark: '#5F5390',
  accent: '#5FB8A0',
  accentLight: '#E4F4EF',
  bg: '#F7F3FA',
  cardBg: '#FFFFFF',
  textPrimary: '#37323F',
  textSecondary: '#6B6578',
  textHint: '#B5AEC4',
  border: '#E9E2F0',
  success: '#7BA37B',
  warning: '#D9973B',
  danger: '#C0503F',
  white: '#FFFFFF',
  glitter: '#F2B8C6'
};

ColorPalette接口定义了16个颜色字段,覆盖了应用所需的全部色彩语义。primary字段代表品牌主色紫色#7B6CAE,这是整个应用的视觉基调,用于导航栏选中态、主操作按钮和品牌标识。primaryLight和primaryDark分别为主色的浅色和深色变体,用于hover态、disabled态和渐变效果。accent字段为青色#5FB8A0,作为辅助色用于进度条、状态标签和次要操作按钮。textPrimary、textSecondary、textHint三个字段构成了三级文本颜色体系,分别用于标题、正文和提示文字,这种分层设计确保了信息层级的清晰传达。

COLORS常量以ColorPalette接口为类型约束进行实例化。值得一提的是glitter字段(#F2B8C6),这是一个粉色调的点缀色,专门用于动效粒子中的亮粉星芒效果,与UV滴胶饰品中常见的亮粉装饰相呼应。这种将业务语义融入色彩命名的设计方式,使得代码的可读性和可维护性都得到了提升。在后续的所有@Builder和build方法中,开发者只需引用COLORS.xxx即可获取对应颜色,无需重复书写十六进制色值,极大地降低了出错概率。

2.2 图表与课程接口定义

在这里插入图片描述

除了色彩系统外,应用还定义了多个数据接口用于图表展示和课程管理。

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

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

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

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

WeekChartItem接口用于本周每日出模数量的柱状图展示,包含label(星期标签)和value(数量值)两个字段。ResinChartItem接口在WeekChartItem的基础上增加了color字段,用于作品成分构成图中的彩色进度条展示,每种成分对应一种颜色。CourseItem接口用于滴胶课堂的课程进度展示,包含课程标题、周次、进度百分比和主题颜色四个字段。PlayTypeItem接口用于首页的玩法四格展示,包含玩法名称、描述、图标和是否热门的标记。这些接口的定义体现了ArkTS强类型语言的优势——每个字段的类型都经过明确声明,编译器能够在开发阶段就发现类型不匹配的错误。

三、@Observed数据模型类深度分析

3.1 PostItem——晶友动态数据模型

在这里插入图片描述

PostItem是社区动态的核心数据模型,使用@Observed装饰器标记为可观察对象。在HarmonyOS ArkTS API 24中,@Observed装饰器使得类的实例能够被框架追踪状态变化,当实例的属性发生改变时,与之绑定的UI组件会自动更新。

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

PostItem类包含6个属性:id(唯一标识)、nick(昵称)、avatar(头像emoji)、text(动态内容)、time(发布时间)和likes(点赞数)。构造函数采用参数赋值的方式初始化所有属性,这种简洁的写法在ArkTS中非常常见。值得注意的是avatar字段使用emoji字符串而非图片URL,这是一种轻量化的头像方案——在社区动态列表中,每个用户的头像用emoji图标表示,既减少了网络请求的开销,又增添了趣味性。当用户发布新的晶语动态时,系统会创建一个id为999的PostItem实例并插入到postList的头部,这一操作会自动触发列表UI的更新。

3.2 ResinItem——成品数据模型

在这里插入图片描述

ResinItem是成品橱窗的数据模型,用于展示工坊中在售的UV滴胶和热缩片作品。

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

ResinItem的name字段存储成品名称(如"星河滴胶吊坠·蓝紫"),craft字段存储工艺描述(如"925银·含链"),price字段以字符串形式存储价格(如"¥128/件"),level字段存储分类标签(如"人气"、“新品”、"定制"等),hot字段为关注度数值。这些字段的设计充分考虑了手作成品的展示需求——用户不仅需要看到成品名称和价格,还需要了解工艺细节和受欢迎程度。hot字段同时被用于成品橱窗中的关注度进度条展示,通过Stack+Row的组合实现了一个简洁的进度条效果。

3.3 WorkItem与GearItem——作品单与材料数据模型

在这里插入图片描述

WorkItem和GearItem分别管理用户的作品进度和材料库存。

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

WorkItem的status字段使用字符串枚举值(“固化中”、“打磨中”、“脱模中”、“已交付”、“待开工”)来表示作品的当前工序状态。这种设计虽然不如枚举类型严谨,但在ArkTS的声明式UI中,字符串状态值可以直接用于条件判断和文本展示,代码更加直观。GearItem的结构最为简单,仅包含id、name、desc和icon四个字段,其中icon同样使用emoji图标。这两个数据模型配合statusColor和statusProgress纯函数,实现了作品进度的可视化展示——不同的状态对应不同的颜色和进度百分比,用户可以一目了然地了解每件作品的制作进度。

四、静态数据数组与导航配置

在这里插入图片描述

4.1 顶层静态数据初始化

应用的所有初始数据都以静态数组的形式在顶层定义。这些数组在应用启动时即被创建,并赋值给组件的@State变量。

const POST_LIST: PostItem[] = [
  new PostItem(1, '固化灯守护者', '💠', '星河吊坠第二浇定型,气泡排干净了。', '5分钟前', 96),
  new PostItem(2, '热缩片玩家', '🎀', '热缩到三分之一的迷你耳饰太可爱。', '22分钟前', 88),
  new PostItem(3, '模具收藏家', '🧊', '新到的硅胶波浪模,脱模零瑕疵。', '40分钟前', 77),
  new PostItem(4, '晶语主理人', '💎', '周末固化工坊开放12个位,含材料。', '1小时前', 70),
  new PostItem(5, '色精调色师', '🎨', '雾霾蓝加一滴紫,像把黄昏封进去。', '2小时前', 63),
  new PostItem(6, '滴胶手账', '📒', '第38件成品:干花戒指托完成。', '3小时前', 55),
  new PostItem(7, '夜光实验员', '🌙', '加了夜光粉的星星,关灯后绝了。', '5小时前', 47),
  new PostItem(8, '亲子手作团', '👨‍👩‍👧', '孩子做的热缩书签,全班都想要。', '昨天', 42)
];

POST_LIST数组初始化了8条社区动态,每条动态都包含了一个真实的用户昵称、emoji头像、动态内容、发布时间和点赞数。这些数据的设计贴近真实场景——"固化灯守护者"讨论的是UV灯固化技巧,"热缩片玩家"分享的是热缩耳饰的制作心得,"模具收藏家"评价的是硅胶模具的脱模效果。这种贴近业务真实的数据设计不仅使得应用在演示时更加逼真,也为后续的数据驱动UI渲染提供了丰富的测试用例。

RESIN_LIST、WORK_LIST、GEAR_LIST等数组也以类似的方式初始化,分别包含8条成品数据、8条作品单数据和8条材料数据。此外还有WEEK_CHART(本周每日出模数)、RESIN_CHART(作品成分构成)、TYPE_LIST(玩法四格)和COURSE_LIST(课程进度)等辅助数据数组。所有这些静态数据数组共同构成了应用的初始数据层,当用户执行发布、编辑、删除等操作时,这些数据会被动态修改,从而驱动UI的实时更新。

4.2 导航配置

在这里插入图片描述

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

const NAV_LIST: NavItem[] = [
  { icon: '💎', label: '首页' },
  { icon: '🎀', label: '橱窗' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '成品橱窗', '我的作品', '固化工坊', '滴胶课堂', '晶友圈', '材料库'];

NAV_LIST定义了底部导航栏的4个主Tab,每个Tab包含一个emoji图标和一个文字标签。SUB_NAV_LIST定义了首页内部的7个内容子Tab。这种将导航配置抽离为独立常量的做法,使得导航结构的修改变得非常简单——只需修改数组内容即可,无需改动UI构建代码。

五、纯函数体系深度解析

在这里插入图片描述

5.1 柱状图与颜色映射函数

应用中定义了一系列纯函数用于数据处理和颜色映射。这些函数不依赖任何外部状态,输入确定则输出确定,是函数式编程思想在ArkTS中的体现。

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

function rcColor(v: number): string {
  if (v > 9) {
    return '#7B6CAE';
  } else if (v > 6) {
    return '#5FB8A0';
  }
  return '#F2B8C6';
}

barH函数根据数值和最大值计算柱状图的高度,最大高度为118像素。这个函数在首页的"本周每日出模数"柱状图中被调用,为每个柱子动态计算高度。rcColor函数根据出模数量返回对应的颜色——超过9件返回主色紫色,超过6件返回辅色青色,其余返回粉色。这种基于数值阈值的颜色映射机制,使得柱状图能够通过颜色直观传达"高产日"与"普通日"的信息差异。

5.2 气泡动效函数

气泡悬浮动效是应用的核心视觉特色之一,通过一组纯函数计算气泡的位置、透明度和大小。

function bubbleX(tick: number, i: number): number {
  return 40 + ((tick * 3 + i * 211) % 600);
}

function bubbleY(tick: number, i: number): number {
  return 640 - ((tick * 7 + i * 173) % 620);
}

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

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

bubbleX和bubbleY函数根据全局时钟tick和粒子索引i计算气泡的x、y坐标。这里使用取模运算(%)来制造周期性的位置变化,而乘以不同的系数(3和7用于x,7和173用于y)确保了每个气泡的运动轨迹各不相同。bubbleA函数计算透明度,通过(tick+i)%4产生0-3的循环值,乘以0.08后加上基础透明度0.12,使得气泡的透明度在0.12到0.36之间周期性变化。bubbleS函数根据索引计算气泡大小,三个尺寸分别为6px、10px和14px,模拟了真实气泡的大小差异。这种基于数学运算的动效系统不需要任何动画框架支持,仅通过setInterval定时器驱动tick值的变化即可实现流畅的粒子动画。

5.3 星芒动效函数

function starA(tick: number, i: number): number {
  return ((tick * 9 + i * 40) % 360);
}

function starO(tick: number, i: number): number {
  if ((tick + i * 2) % 5 === 0) {
    return 0.9;
  }
  return 0.25;
}

function starX(tick: number, i: number): number {
  return 30 + ((tick * 5 + i * 257) % 610);
}

function starY(tick: number, i: number): number {
  return 60 + ((tick * 4 + i * 163) % 610);
}

星芒动效函数与气泡函数的设计思路一致,但增加了旋转角度(starA)和闪烁透明度(starO)两个维度。starA函数通过(tick9+i40)%360计算旋转角度,使得每个星芒以不同的速度旋转。starO函数的设计尤为巧妙——通过(tick+i*2)%5===0的条件判断,每5个tick中有且仅有一次返回高透明度0.9,其余返回低透明度0.25,从而实现了星芒的闪烁效果。这种基于取模运算的闪烁机制简单而高效,不需要任何缓动函数或关键帧动画。

5.4 状态颜色与进度映射函数

function statusColor(s: string): string {
  if (s === '已交付') {
    return '#7BA37B';
  } else if (s === '固化中') {
    return '#7B6CAE';
  } else if (s === '打磨中') {
    return '#5FB8A0';
  } else if (s === '脱模中') {
    return '#F2B8C6';
  }
  return '#B5AEC4';
}

function statusProgress(s: string): number {
  if (s === '已交付') {
    return 100;
  } else if (s === '固化中') {
    return 72;
  } else if (s === '打磨中') {
    return 55;
  } else if (s === '脱模中') {
    return 48;
  }
  return 10;
}

statusColor和statusProgress函数分别根据作品状态字符串返回对应的颜色和进度百分比。已交付状态对应绿色和100%进度,固化中状态对应紫色和72%进度,打磨中状态对应青色和55%进度,脱模中状态对应粉色和48%进度,待开工状态对应灰色和10%进度。这两个函数在pageCrafts构建器中被调用,用于动态设置作品进度条的颜色和宽度。这种将状态映射逻辑封装为纯函数的做法,使得状态展示规则集中管理、一处修改处处生效,是DRY原则的优秀体现。

六、组件状态与生命周期

6.1 @State变量声明

PageResinCraft组件是整个应用的入口组件,通过@Entry和@Component装饰器标记。组件内部声明了大量的@State变量来管理应用状态。

@Entry
@Component
struct PageResinCraft {
  @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 cureOpen: 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 cureStyle: string = ''
  @State cureQty: string = ''
  @State postList: PostItem[] = POST_LIST
  @State workList: WorkItem[] = WORK_LIST
  @State fxTimer: number = -1

这些@State变量可以分为四类:导航状态(mainTab、subTab)、动效状态(tick、glow)、弹窗状态(addOpen、editOpen、delOpen、cureOpen)和表单数据状态(editIdx、editName、editStyle、editNote、addTitle、addContent、cureStyle、cureQty、delTarget)。每一类状态都承担着明确的职责。mainTab和subTab共同决定了当前显示的页面内容,tick和glow驱动粒子动效的持续运行,四个弹窗布尔值控制四种模态弹窗的显示与隐藏,表单数据状态则在弹窗打开时暂存用户输入。postList和workList作为@State数组,其元素的变化(增删改)会自动触发列表UI的更新。

6.2 生命周期与定时器管理

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和aboutToDisappear是HarmonyOS ArkTS API 24中@Component组件的生命周期回调。aboutToAppear在组件创建后、build方法执行前被调用,这里用于启动一个90毫秒间隔的setInterval定时器。定时器每90毫秒将tick值加1、glow值在0和1之间切换,从而驱动粒子动效的持续运行。aboutToDisappear在组件销毁前被调用,这里用于清除定时器以避免内存泄漏。fxTimer变量存储定时器ID,初始值为-1表示未启动,清除后也重置为-1。这种"启动-清除"配对的生命周期管理是HarmonyOS应用开发中的标准实践,确保了组件销毁后不会有孤儿定时器继续执行。

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

openCure(): void {
  this.cureStyle = '';
  this.cureQty = '';
  this.cureOpen = true;
}

这组open方法分别用于打开四种弹窗。openAdd方法在打开前清空表单字段,确保每次发布晶语都是全新的输入。openEdit方法接收一个索引参数,将对应作品单的数据预填到表单字段中,实现"编辑"语义。openDelA和openDelB方法通过设置delTarget变量来区分删除作品单和退课两种操作,这种设计使得同一个delModalBody弹窗能够服务两种不同的删除场景。openCure方法在打开定制弹窗前清空款式和数量字段。所有open方法的核心操作都是设置对应的xxxOpen布尔值为true,由于这些变量是@State装饰的,赋值后会自动触发UI重新渲染,弹窗随之显示。

6.4 业务执行方法

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

doCure(): void {
  if (this.cureStyle.length > 0) {
    this.postList.unshift(new PostItem(998, '我的晶语', '🎁', `已提交定制单:${this.cureStyle}`, '刚刚', 0));
  }
  this.cureOpen = false;
}

doAdd方法使用unshift将新动态插入到postList的头部,这样新发布的动态会出现在列表最上方。doEdit方法使用splice的替换模式(参数3为替换项数1),将指定索引的作品单替换为新的WorkItem实例。doDel方法根据delTarget的值决定删除workList或postList的首个元素。doCure方法在提交定制单后,将定制信息作为一条新的动态插入到postList中,这是一种巧妙的信息反馈机制——用户提交定制后可以在晶友圈看到自己的定制记录。所有do方法在执行完业务逻辑后都会将对应的弹窗状态设置为false,关闭弹窗。

七、fxLayer动效粒子系统

fxLayer是应用的视觉灵魂所在,它通过@Builder装饰器定义为一个可复用的UI构建片段。

@Builder
fxLayer() {
  Stack({ alignContent: Alignment.TopStart }) {
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(bubbleS(i))
        .height(bubbleS(i))
        .borderRadius(bubbleS(i) / 2)
        .backgroundColor(COLORS.accent)
        .opacity(bubbleA(this.tick, i))
        .translate({ x: bubbleX(this.tick, i), y: bubbleY(this.tick, i) })
    }, (i: number) => i.toString())
    ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
      Text('✦')
        .fontSize(12)
        .fontColor(COLORS.glitter)
        .rotate({ angle: starA(this.tick, i) })
        .opacity(starO(this.tick, i))
        .translate({ x: starX(this.tick, i), y: starY(this.tick, i) })
    }, (i: number) => i.toString())
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

fxLayer构建器使用Stack作为容器,内部包含两组ForEach渲染的粒子。第一组是5个气泡粒子,使用Column组件模拟圆形(通过borderRadius设为宽度的一半),背景色为辅色青色#5FB8A0,透明度和位置由bubbleA、bubbleX、bubbleY函数根据当前tick值动态计算。第二组是6个星芒粒子,使用Text组件渲染"✦"字符,字体颜色为粉色#F2B8C6,旋转角度和透明度由starA和starO函数动态计算。

这个动效系统的精妙之处在于hitTestBehavior(HitTestMode.None)的设置。HitTestMode.None意味着这个Stack容器不会拦截任何触摸事件,所有的点击、滑动操作都会穿透到下层的UI组件。这样一来,粒子动效可以覆盖整个屏幕但不影响用户的任何交互操作,实现了"视觉层"与"交互层"的完美分离。当setInterval每90毫秒将tick值加1时,所有粒子的位置、透明度、旋转角度都会重新计算,由于这些属性值是通过@State变量tick绑定的,框架会自动触发ForEach的重新渲染,从而产生流畅的动画效果。

八、header头部构建器分析

8.1 搜索条与购物车

header构建器是首页顶部区域的核心UI,包含了搜索条、固化进度卡、新人礼横幅和数据四格四个功能模块。

@Builder
header() {
  Column({ space: 10 }) {
    Row({ space: 10 }) {
      Row({ space: 6 }) {
        Text('🔍')
          .fontSize(14)
        Text('搜成品 / 材料 / 定制款')
          .fontSize(12)
          .fontColor(COLORS.textHint)
          .layoutWeight(1)
      }
      .layoutWeight(1)
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      Stack({ alignContent: Alignment.TopEnd }) {
        Text('🛒')
          .fontSize(22)
        Text('3')
          .fontSize(9)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 4, right: 4, top: 1, bottom: 1 })
          .backgroundColor(COLORS.danger)
          .borderRadius(8)
          .translate({ x: 6, y: -4 })
      }
      .width(34)
      .height(30)
    }
    .width('100%')

搜索条采用Row嵌套Row的结构,外层Row使用layoutWeight(1)占据除购物车外的全部宽度,内层Row包含搜索图标和占位文字。购物车使用Stack实现图标与角标的叠加,角标通过translate({x:6, y:-4})偏移到右上角,背景色为danger红色,模拟了电商应用中常见的购物车角标效果。

8.2 固化进度卡与数据四格

    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text('3 号固化灯 · 星河吊坠固化中')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('UV 90 秒 · 气泡已排 · 剩 2 层')
          .fontSize(10)
          .fontColor('#F3EFF9')
        Row({ space: 6 }) {
          Text('90 秒')
            .fontSize(9)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDark)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS.accent)
            .borderRadius(6)
          Text('高透硬胶 · 无气泡')
            .fontSize(9)
            .fontColor('#F3EFF9')
        }
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Text('定制下单')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .padding({ left: 12, right: 12, top: 8, bottom: 8 })
        .backgroundColor(COLORS.accent)
        .borderRadius(14)
        .onClick(() => {
          this.openCure();
        })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.primary)
    .borderRadius(16)

固化进度卡使用主色紫色作为背景,白色文字确保对比度。卡片左侧展示当前固化灯位和作品信息,右侧的"定制下单"按钮使用辅色青色作为背景,点击后调用openCure方法打开定制弹窗。这种主色背景+辅色按钮的配色方案在HarmonyOS应用中非常常见,能够有效引导用户的视觉焦点到操作按钮上。

数据四格区域使用Row嵌套4个Column的等分布局,每个格子通过layoutWeight(1)占据相等的宽度,分别展示在售成品数、今日出模数、排队定制数和晶币余额。每个格子使用不同的颜色(主色、辅色、成功色、警告色)来区分数据类型,使得四格数据在视觉上既有统一感又有层次感。

九、subNav子导航与内容页面分析

9.1 横排圆盘序号式子导航

subNav构建器实现了首页7个内容子Tab的横向滚动导航,采用了独特的"圆盘序号式"设计。

@Builder
subNav() {
  Scroll() {
    Row({ space: 13 }) {
      ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
        Row({ space: 5 }) {
          Text(`${idx + 1}`)
            .fontSize(9)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.subTab === idx ? COLORS.white : COLORS.primary)
            .textAlign(TextAlign.Center)
            .width(16)
            .height(16)
            .borderRadius(8)
            .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.primaryLight)
          Text(item)
            .fontSize(this.subTab === idx ? 13 : 11)
            .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.subTab === idx ? COLORS.primary : COLORS.textSecondary)
        }
        .padding({ top: 6, bottom: 6 })
        .onClick(() => {
          this.subTab = idx;
        })
      }, (item: string) => item)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .padding({ top: 6, bottom: 10 })
}

每个子Tab项由一个16x16的圆盘序号和文字标签组成。圆盘序号通过width(16).height(16).borderRadius(8)实现完美的圆形,背景色在选中态为primary紫色、非选中态为primaryLight浅紫色,序号文字颜色相应地在白色和紫色之间切换。文字标签的字号和字重也会根据选中状态变化——选中时字号13且加粗,非选中时字号11且常规。整个子导航包裹在Scroll容器中,设置scrollable(ScrollDirection.Horizontal)实现横向滚动,scrollBar(BarState.Off)隐藏滚动条,使得7个子Tab能够流畅地横向滑动选择。

9.2 pageFeatured精选页面

pageFeatured构建器是首页"精选"子Tab的内容,包含了玩法四格、本周出模柱状图、作品成分构成、人气成品双列和晶友动态五个模块。

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

玩法四格使用ForEach遍历TYPE_LIST数组,每个格子通过layoutWeight(1)等分宽度。maxLines(1)和textOverflow({overflow: TextOverflow.Ellipsis})的组合确保了描述文字在空间不足时以省略号截断,避免了文字溢出导致的布局错乱。热门标签通过it.hot ? ‘热门’ : ’ '的三元表达式控制显示,热门时显示红色文字,非热门时显示空白,保持布局高度一致。

本周出模柱状图是pageFeatured中最具技术含量的模块。它使用ForEach遍历WEEK_CHART数组,为每个数据项渲染一个Column容器,内含一个柱子(通过width(20).height(barH(it.value, 13))动态计算高度的Column)、数值标签和星期标签。柱子的颜色由rcColor函数根据数值动态决定,整个柱状图区域的height(140)和alignItems(VerticalAlign.Bottom)确保了所有柱子底部对齐。

9.3 pageCrafts作品进度页面

pageCrafts构建器展示用户的作品单列表,每个作品项包含状态标签、进度条和编辑入口。

@Builder
pageCrafts() {
  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%')
}

每个作品项使用Stack叠加两层Row来构建进度条——底层Row为灰色背景轨道,上层Row的宽度通过${statusProgress(it.status)}%动态设置为状态对应的进度百分比,颜色由statusColor函数决定。这种Stack+双层Row的进度条实现方式是HarmonyOS ArkTS中最常见的进度条模式,简洁而高效。"编辑作品单"链接通过onClick调用openEdit(idx),将当前作品项的索引传入,实现精准的编辑操作。

十、模态弹窗系统分析

10.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;
        this.cureOpen = false;
      })
  }
  .width('100%')
  .height('100%')
}

modalOverlay是所有弹窗共享的遮罩层,使用黑色半透明背景(opacity 0.6)遮盖底层内容。点击遮罩层会将所有弹窗状态设置为false,关闭当前打开的弹窗。这种统一的遮罩层设计简化了弹窗管理——无需为每个弹窗单独编写关闭逻辑,一个onClick回调即可处理所有弹窗的关闭操作。

10.2 addModalBody发布晶语弹窗

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

addModalBody弹窗包含标题、TextInput输入框、TextArea文本域和取消/发布按钮组。TextInput通过text参数与@State变量addTitle双向绑定,onChange回调将输入值同步回状态变量。TextArea的处理方式类似,与addContent绑定。按钮组采用了一种特殊的实现方式——Button组件本身没有文字,而是在其旁边放置Text组件并通过负margin重叠上去,这种做法虽然不够优雅,但在某些HarmonyOS版本中能够绕过Button文字样式的限制。取消按钮的背景色为bg浅色,发布按钮的背景色为primary紫色,颜色差异引导用户优先点击发布。

10.3 cureModalBody定制下单弹窗

@Builder
cureModalBody() {
  Column({ space: 12 }) {
    Text('滴胶定制下单')
      .fontSize(17)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
    TextInput({ placeholder: '定制款式(如 星河吊坠)', text: this.cureStyle })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(40)
      .onChange((v: string) => {
        this.cureStyle = v;
      })
    TextInput({ placeholder: '定制数量(如 2 件)', text: this.cureQty })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(40)
      .onChange((v: string) => {
        this.cureQty = v;
      })
    Text('匠人确认排期后短信通知 · 含免费刻字与礼盒包装')
      .fontSize(11)
      .fontColor(COLORS.textHint)
      .width('100%')
    Row({ space: 10 }) {
      Button()
        .layoutWeight(1)
        .height(38)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .onClick(() => {
          this.cureOpen = false;
        })
      Text('取消')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
        .margin({ left: -52 })
      Button()
        .layoutWeight(1)
        .height(38)
        .backgroundColor(COLORS.accent)
        .borderRadius(8)
        .onClick(() => {
          this.doCure();
        })
      Text('提交定制')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .margin({ left: -56 })
    }
    .width('100%')
  }
  .width('100%')
  .padding(16)
  .backgroundColor(COLORS.cardBg)
}

cureModalBody弹窗用于滴胶定制下单,包含款式输入、数量输入和说明文字。与addModalBody不同的是,提交按钮的背景色使用的是accent青色而非primary紫色,这是因为在应用的色彩语义中,primary色用于"发布"类操作,accent色用于"预约/定制"类操作。说明文字"匠人确认排期后短信通知·含免费刻字与礼盒包装"通过fontColor(COLORS.textHint)以浅色展示,作为辅助信息存在。

十一、build主架构与弹窗条件渲染

11.1 build方法整体结构

build方法是PageResinCraft组件的入口方法,定义了整个应用的UI骨架。

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

build方法使用Stack作为根容器,从底到顶依次堆叠了背景层(纯色背景Column)、特效层(fxLayer粒子动效)、内容层(mainContent+bottomBar)和弹窗层(四个条件渲染的Stack)。这种分层架构确保了视觉层次的正确性——粒子动效位于内容之下但不遮挡背景,弹窗位于所有内容之上。

弹窗的条件渲染使用了if语句配合@State布尔变量。当addOpen为true时,对应的Stack+modalOverlay+addModalBody结构会被渲染到UI树中;当addOpen变为false时,该结构会被自动移除。每个弹窗的容器宽度为88%,通过constraintSize({maxHeight: ‘80%’})限制最大高度为80%,避免内容过多时撑满屏幕。zIndex(999)确保弹窗始终位于最顶层。

11.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.pageMarket()
          } else if (this.subTab === 2) {
            this.pageCrafts()
          } else if (this.subTab === 3) {
            this.pageWorkshop()
          } else if (this.subTab === 4) {
            this.pageCourse()
          } else if (this.subTab === 5) {
            this.pageCircle()
          } else {
            this.pageGear()
          }
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .padding({ left: 14, right: 14, bottom: 20 })
    } else if (this.mainTab === 1) {
      Scroll() {
        Column() {
          this.pageMarket()
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .padding({ left: 14, right: 14, bottom: 20 })
    } else if (this.mainTab === 2) {
      Scroll() {
        Column() {
          this.pageCourse()
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .padding({ left: 14, right: 14, bottom: 20 })
    } else {
      Scroll() {
        Column() {
          this.pageMine()
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .padding({ left: 14, right: 14, bottom: 20 })
    }
  }
  .width('100%')
  .height('100%')
}

mainContent构建器实现了双层Tab的内容路由逻辑。当mainTab为0(首页)时,先渲染subNav子导航,再根据subTab的值选择7个页面构建器之一进行渲染,整个内容区域包裹在垂直滚动的Scroll容器中。当mainTab为1(橱窗)时,直接渲染pageMarket;当mainTab为2(课堂)时,渲染pageCourse;当mainTab为3(我的)时,渲染pageMine。所有内容区域都使用相同的Scroll配置——垂直滚动、隐藏滚动条、layoutWeight(1)填满剩余空间、左右各14的padding。这种统一的内容容器配置确保了不同Tab下的内容在视觉表现上保持一致。

十二、Mermaid架构流程图

tick驱动

tick驱动

mainTab=0

mainTab=1

mainTab=2

mainTab=3

addOpen

editOpen

delOpen

cureOpen

doAdd

doEdit

doDel

doCure

openCure

openEdit

openDelB

openDelA

openAdd

PageResinCraft 入口组件

build 主架构

背景层 Column

特效层 fxLayer

内容层 mainContent + bottomBar

弹窗层 条件渲染

气泡粒子 ForEach x5

星芒粒子 ForEach x6

header 头部区域

mainContent 内容路由

bottomBar 底部导航

首页: subNav + 7子页面

橱窗: pageMarket

课堂: pageCourse

我的: pageMine

精选 pageFeatured

成品橱窗 pageMarket

我的作品 pageCrafts

固化工坊 pageWorkshop

滴胶课堂 pageCourse

晶友圈 pageCircle

材料库 pageGear

发布晶语弹窗 addModalBody

编辑作品单弹窗 editModalBody

删除/退课弹窗 delModalBody

定制下单弹窗 cureModalBody

postList 数据更新

workList 数据更新

上图展示了PageResinCraft组件的完整架构。从build方法出发,应用被分为四个层级:背景层提供纯色底色,特效层通过setInterval驱动的tick变量持续更新粒子位置,内容层通过mainTab和subTab两级路由分发到7个内容页面,弹窗层通过四个布尔状态变量条件渲染四种模态弹窗。数据流方面,所有弹窗的操作最终都归结为对postList和workList两个@State数组的增删改,这些数据变化会自动驱动列表UI的更新。

十三、数据模型与组件对比表格

维度PostItemResinItemWorkItemGearItem
业务语义晶友社区动态成品橱窗商品用户作品进度单材料库物资
属性数量6个6个5个4个
核心字段nick, text, likesname, price, levelstatus, progressname, desc, icon
装饰器@Observed@Observed@Observed@Observed
可变性可增(unshift)只读展示可改(splice替换)只读展示
使用页面pageCircle, pageFeaturedpageMarket, pageWorkshoppageCrafts, pageMinepageGear, pageWorkshop
颜色映射levelColor, resTagColorstatusColor, statusProgress
弹窗关联addModalBody, cureModalBodyeditModalBody, delModalBody
维度fxLayer特效层header头部subNav子导航bottomBar底部导航
构建器类型@Builder@Builder@Builder@Builder
核心功能气泡悬浮+星芒闪烁搜索+进度卡+数据四格7子Tab横排圆盘序号4主Tab底部导航
交互行为HitTestMode.None(穿透)可点击(搜索/定制)可点击(切换subTab)可点击(切换mainTab)
状态依赖tick, glow无(静态)subTabmainTab
动效驱动setInterval 90ms
渲染方式ForEach x2直接渲染ForEach(SUB_NAV_LIST)ForEach(NAV_LIST)

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 场景:UV 滴胶饰品 / 热缩片手作 / 固化工坊 / 晶友社区
// 底部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;
  glitter: string;
}

const COLORS: ColorPalette = {
  primary: '#7B6CAE',
  primaryLight: '#EEEBF6',
  primaryDark: '#5F5390',
  accent: '#5FB8A0',
  accentLight: '#E4F4EF',
  bg: '#F7F3FA',
  cardBg: '#FFFFFF',
  textPrimary: '#37323F',
  textSecondary: '#6B6578',
  textHint: '#B5AEC4',
  border: '#E9E2F0',
  success: '#7BA37B',
  warning: '#D9973B',
  danger: '#C0503F',
  white: '#FFFFFF',
  glitter: '#F2B8C6'
};

// ============ 晶友动态数据模型 ============
@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 ResinItem {
  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 ResinChartItem {
  label: string;
  value: number;
  color: string;
}

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

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

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '固化灯守护者', '💠', '星河吊坠第二浇定型,气泡排干净了。', '5分钟前', 96),
  new PostItem(2, '热缩片玩家', '🎀', '热缩到三分之一的迷你耳饰太可爱。', '22分钟前', 88),
  new PostItem(3, '模具收藏家', '🧊', '新到的硅胶波浪模,脱模零瑕疵。', '40分钟前', 77),
  new PostItem(4, '晶语主理人', '💎', '周末固化工坊开放 12 个位,含材料。', '1小时前', 70),
  new PostItem(5, '色精调色师', '🎨', '雾霾蓝加一滴紫,像把黄昏封进去。', '2小时前', 63),
  new PostItem(6, '滴胶手账', '📒', '第 38 件成品:干花戒指托完成。', '3小时前', 55),
  new PostItem(7, '夜光实验员', '🌙', '加了夜光粉的星星,关灯后绝了。', '5小时前', 47),
  new PostItem(8, '亲子手作团', '👨‍👩‍👧', '孩子做的热缩书签,全班都想要。', '昨天', 42)
];

const RESIN_LIST: ResinItem[] = [
  new ResinItem(1, '星河滴胶吊坠 · 蓝紫', '925 银 · 含链', '¥128/件', '人气', 92),
  new ResinItem(2, '干花标本戒指托', '真实三色堇 · 封存', '¥88/枚', '手作', 86),
  new ResinItem(3, '热缩片耳饰 · 薄荷', '渐变 · 超轻', '¥69/对', '新品', 81),
  new ResinItem(4, '夜光星尘摆件', '吸光 10 分钟', '¥156/个', '夜光', 78),
  new ResinItem(5, '海洋纹手机壳', '树脂 · 防黄层', '¥98/个', '定制', 72),
  new ResinItem(6, '金箔书签 · 长款', '碎金 · 流苏', '¥59/枚', '人气', 68),
  new ResinItem(7, '滴胶钥匙扣定制', '照片 · 姓名刻字', '¥45/个', '定制', 60),
  new ResinItem(8, '热缩胸针盲盒', '随机 3 款', '¥39/盒', '入门', 54)
];

const WORK_LIST: WorkItem[] = [
  new WorkItem(1, '星河吊坠 · 第二浇', '3 号灯 · UV 90 秒', '固化中', 72),
  new WorkItem(2, '干花戒指托', '打磨 · 边缘抛光', '打磨中', 55),
  new WorkItem(3, '夜光星尘摆件', '吸光测试中', '固化中', 66),
  new WorkItem(4, '热缩薄荷耳饰', '已缩型 · 封层', '脱模中', 48),
  new WorkItem(5, '闺蜜生日礼盒', '3 件套 · 待排产', '待开工', 15),
  new WorkItem(6, '海洋纹手机壳', '防黄层 · 晾置', '打磨中', 58),
  new WorkItem(7, '照片钥匙扣', '客户照片 · 刻字', '已交付', 100),
  new WorkItem(8, '金箔长书签', '流苏组装', '已交付', 100)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, 'UV 固化灯 36W', '360° 旋钮 · 快速定型', '🔆'),
  new GearItem(2, '硅胶波浪模', '食品级 · 易脱模', '🧊'),
  new GearItem(3, '硬滴胶 A/B 胶', '3:1 配比 · 高透', '🧴'),
  new GearItem(4, '热缩片 · 透明', '20×29 · 可喷墨', '📄'),
  new GearItem(5, '色精套装 12 色', '透明系专用', '🎨'),
  new GearItem(6, '夜光粉 · 星蓝', '吸光 10 分钟', '✨'),
  new GearItem(7, '打磨砂纸组', '400-3000 目', '🪨'),
  new GearItem(8, '镶钻镊子 · 弯头', '宝石定位', '🥢')
];

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

const RESIN_CHART: ResinChartItem[] = [
  { label: '滴胶封存', value: 32, color: '#7B6CAE' },
  { label: '热缩手作', value: 26, color: '#5FB8A0' },
  { label: '干花标本', value: 18, color: '#F2B8C6' },
  { label: '夜光金箔', value: 14, color: '#D9973B' },
  { label: '其他配饰', value: 10, color: '#7BA37B' }
];

const TYPE_LIST: PlayTypeItem[] = [
  { name: '滴胶饰品', desc: '吊坠 · 戒指 · 耳饰', icon: '💎', hot: true },
  { name: '热缩手作', desc: '书签 · 胸针 · 耳饰', icon: '🎀', hot: true },
  { name: '夜光系列', desc: '星尘 · 摆件 · 挂件', icon: '🌙', hot: false },
  { name: '定制礼盒', desc: '照片 · 刻字 · 三件套', icon: '🎁', hot: false }
];

const COURSE_LIST: CourseItem[] = [
  { title: '滴胶入门与安全规范', week: '第 1 周', progress: 100, color: '#7B6CAE' },
  { title: '配色与色精进阶', week: '第 2 周', progress: 84, color: '#5FB8A0' },
  { title: '热缩片塑形与封层', week: '第 3 周', progress: 56, color: '#F2B8C6' },
  { title: '气泡消除与打磨抛光', week: '第 4 周', progress: 30, color: '#7BA37B' },
  { title: '定制接单与定价实务', week: '第 5 周', progress: 10, color: '#D9973B' }
];

// ============ 导航配置 ============
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 rcColor(v: number): string {
  if (v > 9) {
    return '#7B6CAE';
  } else if (v > 6) {
    return '#5FB8A0';
  }
  return '#F2B8C6';
}

function bubbleX(tick: number, i: number): number {
  return 40 + ((tick * 3 + i * 211) % 600);
}

function bubbleY(tick: number, i: number): number {
  return 640 - ((tick * 7 + i * 173) % 620);
}

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

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

function starA(tick: number, i: number): number {
  return ((tick * 9 + i * 40) % 360);
}

function starO(tick: number, i: number): number {
  if ((tick + i * 2) % 5 === 0) {
    return 0.9;
  }
  return 0.25;
}

function starX(tick: number, i: number): number {
  return 30 + ((tick * 5 + i * 257) % 610);
}

function starY(tick: number, i: number): number {
  return 60 + ((tick * 4 + i * 163) % 610);
}

function statusColor(s: string): string {
  if (s === '已交付') {
    return '#7BA37B';
  } else if (s === '固化中') {
    return '#7B6CAE';
  } else if (s === '打磨中') {
    return '#5FB8A0';
  } else if (s === '脱模中') {
    return '#F2B8C6';
  }
  return '#B5AEC4';
}

function statusProgress(s: string): number {
  if (s === '已交付') {
    return 100;
  } else if (s === '固化中') {
    return 72;
  } else if (s === '打磨中') {
    return 55;
  } else if (s === '脱模中') {
    return 48;
  }
  return 10;
}

function resTagColor(g: string): string {
  if (g.indexOf('星河') >= 0 || g.indexOf('夜光') >= 0) {
    return '#7B6CAE';
  } else if (g.indexOf('定制') >= 0) {
    return '#5FB8A0';
  } else if (g.indexOf('干花') >= 0 || g.indexOf('热缩') >= 0) {
    return '#F2B8C6';
  }
  return '#D9973B';
}

function levelColor(s: string): string {
  if (s === '入门') {
    return '#7BA37B';
  } else if (s === '新品') {
    return '#7B6CAE';
  } else if (s === '人气') {
    return '#5FB8A0';
  } else if (s === '定制') {
    return '#D9973B';
  } else if (s === '手作') {
    return '#F2B8C6';
  }
  return '#C0503F';
}

@Entry
@Component
struct PageResinCraft {
  @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 cureOpen: 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 cureStyle: string = ''
  @State cureQty: 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;
  }

  openCure(): void {
    this.cureStyle = '';
    this.cureQty = '';
    this.cureOpen = 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;
  }

  doCure(): void {
    if (this.cureStyle.length > 0) {
      this.postList.unshift(new PostItem(998, '我的晶语', '🎁', `已提交定制单:${this.cureStyle}`, '刚刚', 0));
    }
    this.cureOpen = false;
  }

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column()
          .width(bubbleS(i))
          .height(bubbleS(i))
          .borderRadius(bubbleS(i) / 2)
          .backgroundColor(COLORS.accent)
          .opacity(bubbleA(this.tick, i))
          .translate({ x: bubbleX(this.tick, i), y: bubbleY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
        Text('✦')
          .fontSize(12)
          .fontColor(COLORS.glitter)
          .rotate({ angle: starA(this.tick, i) })
          .opacity(starO(this.tick, i))
          .translate({ x: starX(this.tick, i), y: starY(this.tick, i) })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Column({ space: 10 }) {
      // 搜索条 + 购物车
      Row({ space: 10 }) {
        Row({ space: 6 }) {
          Text('🔍')
            .fontSize(14)
          Text('搜成品 / 材料 / 定制款')
            .fontSize(12)
            .fontColor(COLORS.textHint)
            .layoutWeight(1)
        }
        .layoutWeight(1)
        .padding({ left: 12, right: 12, top: 8, bottom: 8 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        Stack({ alignContent: Alignment.TopEnd }) {
          Text('🛒')
            .fontSize(22)
          Text('3')
            .fontSize(9)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 4, right: 4, top: 1, bottom: 1 })
            .backgroundColor(COLORS.danger)
            .borderRadius(8)
            .translate({ x: 6, y: -4 })
        }
        .width(34)
        .height(30)
      }
      .width('100%')

      // 固化进度卡
      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text('3 号固化灯 · 星河吊坠固化中')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('UV 90 秒 · 气泡已排 · 剩 2 层')
            .fontSize(10)
            .fontColor('#F3EFF9')
          Row({ space: 6 }) {
            Text('90 秒')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.accent)
              .borderRadius(6)
            Text('高透硬胶 · 无气泡')
              .fontSize(9)
              .fontColor('#F3EFF9')
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('定制下单')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .backgroundColor(COLORS.accent)
          .borderRadius(14)
          .onClick(() => {
            this.openCure();
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.primary)
      .borderRadius(16)

      // 新人礼横幅
      Row({ space: 10 }) {
        Text('🎁')
          .fontSize(22)
        Column({ space: 2 }) {
          Text('新人礼 · 首单送热缩体验包')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('周六 14:00 · 固化工坊 · 含全套材料')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('去定制')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor(COLORS.primary)
          .borderRadius(14)
          .onClick(() => {
            this.openCure();
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 数据四格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('在售成品')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('128 件')
            .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('46 件')
            .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('5 单')
            .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('¥240')
            .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: 13 }) {
        ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
          Row({ space: 5 }) {
            Text(`${idx + 1}`)
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.subTab === idx ? COLORS.white : COLORS.primary)
              .textAlign(TextAlign.Center)
              .width(16)
              .height(16)
              .borderRadius(8)
              .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.primaryLight)
            Text(item)
              .fontSize(this.subTab === idx ? 13 : 11)
              .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.subTab === idx ? COLORS.primary : COLORS.textSecondary)
          }
          .padding({ top: 6, bottom: 6 })
          .onClick(() => {
            this.subTab = idx;
          })
        }, (item: string) => item)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .padding({ top: 6, bottom: 10 })
  }

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

      // 本周出模柱状图
      Column() {
        Row() {
          Text('本周每日出模数')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('单位 件')
            .fontSize(10)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        Row({ space: 10 }) {
          ForEach(WEEK_CHART, (it: WeekChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(20)
                .height(barH(it.value, 13))
                .borderRadius(5)
                .backgroundColor(rcColor(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('52%')
            .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(RESIN_CHART, (it: ResinChartItem) => {
          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: ResinChartItem) => 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(RESIN_LIST, (it: ResinItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < RESIN_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('💎')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(RESIN_LIST[idx].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(RESIN_LIST[idx].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(RESIN_LIST[idx].name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${RESIN_LIST[idx].price} · 关注 ${RESIN_LIST[idx].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < RESIN_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🎀')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(RESIN_LIST[idx + 1].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(RESIN_LIST[idx + 1].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(RESIN_LIST[idx + 1].name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${RESIN_LIST[idx + 1].price} · 关注 ${RESIN_LIST[idx + 1].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: ResinItem) => it.id.toString())
      }
      .width('100%')

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

  @Builder
  pageMarket() {
    Column({ space: 10 }) {
      Row() {
        Text('成品橱窗')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('上架我的成品')
          .fontSize(12)
          .fontColor(COLORS.primary)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      ForEach(RESIN_LIST, (it: ResinItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(resTagColor(it.name))
            Column({ space: 4 }) {
              Row() {
                Text(it.name)
                  .fontSize(13)
                  .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(resTagColor(it.name))
          }
          .width('100%')
          Row({ space: 10 }) {
            Text(`关注度 ${it.hot}`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('加入购物车')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(COLORS.primary)
              .borderRadius(8)
              .onClick(() => {
                this.openCure();
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: ResinItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageCrafts() {
    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('3 号 · 星河')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('UV 累计')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('2.4 h')
            .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('46 件')
            .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('2 台')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(RESIN_LIST.slice(0, 5), (it: ResinItem, 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(resTagColor(it.name))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: ResinItem) => 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(13)
                  .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(13)
                  .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('A/B 胶 · 色精')
            .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: -70, 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('紫晶会员 · 成品 214 件 · 接单 32 次 · 陪伴 88 天')
            .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;
          this.cureOpen = 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
  cureModalBody() {
    Column({ space: 12 }) {
      Text('滴胶定制下单')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '定制款式(如 星河吊坠)', text: this.cureStyle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.cureStyle = v;
        })
      TextInput({ placeholder: '定制数量(如 2 件)', text: this.cureQty })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.cureQty = v;
        })
      Text('匠人确认排期后短信通知 · 含免费刻字与礼盒包装')
        .fontSize(11)
        .fontColor(COLORS.textHint)
        .width('100%')
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.cureOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.accent)
          .borderRadius(8)
          .onClick(() => {
            this.doCure();
          })
        Text('提交定制')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -56 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

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

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

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


十四、总结与展望

在这里插入图片描述

本文对基于HarmonyOS 6.1.1和ArkTS API 24的晶语UV滴胶手作工坊应用进行了全面的架构解析。从ColorPalette色彩系统的接口定义到COLORS常量的实例化,从@Observed数据模型类的设计到静态数据数组的初始化,从纯函数体系的数学运算到@Builder UI构建器的声明式渲染,从setInterval驱动的粒子动效系统到条件渲染的模态弹窗架构,我们深入剖析了每一个技术细节。这个应用虽然在业务场景上是一个垂直的手作社区平台,但其技术架构涵盖了HarmonyOS ArkTS开发的几乎所有核心模式——响应式状态管理、组件化UI构建、纯函数数据处理、定时器动效驱动、条件渲染弹窗系统等,是一个极具学习价值的完整案例。

从架构设计的角度来看,本应用展现了多个值得借鉴的最佳实践。首先是"接口定义+常量实例化"的色彩管理模式,通过ColorPalette接口约束COLORS常量的类型,确保了颜色使用的类型安全和集中管理。其次是纯函数体系的运用——barH、rcColor、bubbleX/Y/A/S、starA/O/X/Y、statusColor、statusProgress等一系列纯函数将数据处理逻辑从UI构建中抽离出来,使得代码职责清晰、易于测试。再次是@Builder装饰器的广泛使用,fxLayer、header、subNav、pageFeatured、pageMarket、pageCrafts、pageWorkshop、pageCourse、pageCircle、pageGear、pageMine、modalOverlay、addModalBody、editModalBody、delModalBody、cureModalBody、bottomBar、mainContent等十余个@Builder方法将庞大的UI树拆分为可管理的片段,每个片段职责单一、边界清晰。最后是Stack分层架构的运用——背景层、特效层、内容层、弹窗层从底到顶依次堆叠,通过hitTestBehavior和zIndex实现了视觉层次与交互层次的分离。

从动效系统的角度来看,本应用的粒子动效方案具有独特的技术价值。不同于使用HarmonyOS原生动画API(如animateTo、animation)的方式,本应用采用setInterval定时器驱动tick变量递增,再通过纯函数将tick值映射为粒子的位置、透明度、旋转角度等属性。这种方案的优势在于极致的轻量性——不需要任何动画框架支持,仅依赖Math运算和@State状态绑定即可实现。其劣势在于性能开销与粒子数量成正比,在粒子较多或页面较复杂时可能出现帧率下降。在实际生产环境中,开发者可以考虑将这种方案与HarmonyOS的animateTo API结合使用,在保持设计灵活性的同时提升渲染性能。

从业务场景的角度来看,UV滴胶与热缩片手作工坊这个垂直场景为技术实践提供了丰富的需求驱动。作品进度管理需要状态映射与进度条展示,成品橱窗需要列表渲染与标签分类,社区互动需要动态发布与列表更新,定制下单需要表单输入与数据反馈,课程管理需要进度跟踪与退课操作。这些业务需求自然地映射为ArkTS的技术实现——@Observed数据模型管理可变状态,@Builder构建器拆分UI片段,@State变量驱动响应式更新,纯函数处理业务逻辑,条件渲染管理弹窗生命周期。这种从业务需求到技术实现的清晰映射,正是HarmonyOS ArkTS声明式UI开发范式的核心价值所在。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐