一、HarmonyOS技术背景与大漆工艺数字化的时代契机

HarmonyOS作为华为面向万物互联时代打造的分布式操作系统,自诞生以来便以其独特的微内核架构和一次开发多端部署的能力,深刻改变了移动应用的开发范式。在HarmonyOS 6.1.1版本中,ArkTS作为主力开发语言已经趋于成熟,它基于TypeScript进行了深度扩展,在保留TypeScript类型安全优势的同时,融入了声明式UI编程范式和状态管理能力。ArkTS API 24对应着最新的系统能力集,涵盖了从基础UI组件到高级动画、从状态观察到分布式数据同步的全栈能力,为开发者构建复杂业务场景提供了坚实底座。对于传统工艺类应用而言,这种兼具高性能渲染与精细状态管理的框架,恰好满足了非遗工艺展示、工坊排产管理与匠人社区互动等多维需求。

大漆工艺是中国最为古老的非遗手工技艺之一,其历史可追溯至八千年前的河姆渡文化时期。脱胎漆器作为福州三宝之首,以麻布为胎、以生漆为媒,经过数十遍髹涂与荫干,最终形成轻巧坚韧、光可鉴人的漆器。然而,大漆工艺的数字化进程长期滞后于其他手工业,原因在于其工序极度复杂——从制胎、裱布到髹涂、推光,每一步都涉及温湿度控制、遍数记录和荫房排产等专业参数。传统的表单类应用根本无法承载这种多维度、多角色、多状态的业务模型。HarmonyOS ArkTS的@Observed观察机制与@State状态管理恰好为这种领域驱动的复杂建模提供了语言级支撑,使得每一遍髹涂、每一间荫房的实时状态都能精确映射到UI层。

在开发理念层面,ArkTS倡导"状态驱动UI"的核心思想,这与传统命令式编程有着本质区别。开发者无需手动操作DOM节点或调用refresh方法,只需声明数据模型与UI之间的映射关系,框架便会自动在数据变化时触发精确的局部刷新。这一特性在漆器工坊场景中尤为重要——当某件漆器从"髹漆中"切换到"荫干中"时,进度条颜色、状态标签和排产甘特图需要同步更新,传统方式需要编写大量同步代码,而在ArkTS中仅靠@Observed注解即可实现全局响应。此外,ArkTS的@Builder装饰器支持将复杂UI拆分为可复用的构建函数,极大地提升了代码可维护性,这与工坊中"一道工序一个Builder"的业务结构天然契合。

从业务价值角度来看,构建一款大漆工艺工坊应用需要同时覆盖C端展示与B端管理两个面向。C端面向漆友社区,提供漆器橱窗、动态分享与课堂学习功能,帮助非遗爱好者发现好物、交流心得;B端面向工坊主理人,提供工单管理、荫房监控与原料采购功能,实现从接单到交付的全流程数字化。这种双端融合的架构要求应用在信息架构上做出精心设计——底部导航的四大主Tab分别承载首页聚合、橱窗展示、课堂学习与个人中心,而首页内部又通过七个内容Tab实现"漆面流挂式"的精细化分区。这种导航层次的设计直接借鉴了成熟电商与社区产品的最佳实践,同时融入了大漆工艺特有的业务语义。

本文将以一个完整的脱胎漆器工坊应用源码为蓝本,从色彩体系定义、数据模型建模、静态数据初始化、纯函数工具层、动画特效实现、@Builder组件化拆分、弹框交互逻辑到build主架构组装,进行逐段代码级的深度剖析。通过这篇文章,读者不仅能掌握HarmonyOS ArkTS API 24的核心开发技巧,更能理解如何将一个传统非遗工艺的复杂业务流转化为可运行、可维护、可扩展的移动应用。文章最后还将通过数据模型对比表格,清晰呈现各模型间的字段差异与适用场景,帮助开发者在类似项目中快速复用。
在这里插入图片描述

二、色彩体系与ColorPalette接口定义

在任何一款移动应用中,色彩体系是最先需要确立的设计基础。大漆工艺以朱红与金箔为标志色,因此在应用中采用#B83A26(漆红)与#C9A84C(贴金)作为双主色,辅以#5C3D2E(生漆褐)作为沉稳的底色。这套色彩不仅在视觉上还原了大漆器物温润厚重的质感,更在功能层面承担着状态区分、层级强调与情感传递的多重职责。
在这里插入图片描述

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

const COLORS: ColorPalette = {
  primary: '#B83A26',
  primaryLight: '#F5E4E0',
  primaryDark: '#8A2C1D',
  accent: '#C9A84C',
  accentLight: '#F5EDD8',
  bg: '#F8F3E8',
  cardBg: '#FFFFFF',
  textPrimary: '#3A2A1E',
  textSecondary: '#7A6A5E',
  textHint: '#B5A89E',
  border: '#EBE0D2',
  success: '#6FAF8F',
  warning: '#D9973B',
  danger: '#C0503F',
  white: '#FFFFFF',
  urushi: '#5C3D2E'
};

上面这段代码展示了完整的色彩定义架构。首先通过interface ColorPalette声明了一个类型安全的色彩接口,包含16个语义化字段。其中最值得一提的是urushi字段——"urushi"是日语"漆"的罗马音,这里用它来命名生漆褐色#5C3D2E,体现了开发者对大漆文化的深入理解。接口定义之后,通过const COLORS: ColorPalette将具体的色值注入到这个类型约束中,确保所有字段都有值且类型正确。这种"先接口后常量"的模式是ArkTS类型系统的最佳实践之一,它让色彩引用从魔法字符串升级为有语义、有约束、有补全的常量引用,在后续数百处颜色调用中都能获得编译期检查。

在实际使用中,COLORS.primary代表工坊主色漆红,用于头部进度卡背景、主按钮和选中态Tab指示器;COLORS.accent代表贴金色,用于强调标签、金光横扫特效和髹漆下单按钮;COLORS.urushi代表生漆褐,用于漆膜厚度数据和工单状态中的"打磨中"标签。三层文本色(textPrimary/textSecondary/textHint)则构建了从标题到辅助文字的三级视觉层次。success/warning/danger三色用于状态语义——已验收用绿色、荫干中用金色、退课/取消用红色,这种语义色的一致使用确保了用户在不同页面看到相同颜色时能产生相同的认知预期。

三、@Observed数据模型类的设计与实现

在这里插入图片描述

数据模型是整个应用的骨架。在大漆工坊场景中,需要建模的核心实体包括漆友动态、漆器商品、生产工单和原料物资四类。ArkTS提供了@Observed装饰器来标注可观察的类,当类的实例被@State@Prop引用时,实例内部属性的变化将自动触发UI刷新。这一机制是ArkTS响应式编程的基石,它使得开发者可以在数据层进行任意操作,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
  }
}

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

上述代码展示了PostItem(漆友动态)和LacquerItem(漆器商品)两个核心模型。PostItem包含id、昵称、头像emoji、正文、时间和点赞数六个字段,其中avatar使用emoji而非URL,这是一种轻量化的头像方案——在缺乏CDN资源时,emoji头像既能表达个性又无需网络加载。LacquerItem则增加了craft(工艺描述)、price(价格)、level(级别)和hot(关注度)四个商品特有字段,其中level取值如"典藏"“限定”“人气”“手作”“新品”“入门”,将在后续的levelColor函数中映射为不同的标签底色。两个类都采用了"默认值初始化 + 构造函数赋值"的模式,这是ArkTS中@Observed类的标准写法——先给属性设默认值确保对象创建时不会出现undefined,再在构造函数中接收外部参数。

@Observed
export class CraftItem {
  id: number = 0
  name: string = ''
  item: string = ''
  status: string = ''
  progress: number = 0
  constructor(id: number, name: string, item: string, status: string, progress: number) {
    this.id = id; this.name = name; this.item = item; this.status = status
    this.progress = progress
  }
}

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

CraftItem是工单模型,其中status字段是该模型的核心——它取值"荫干中"“髹漆中”“打磨中”“已验收”“待开工"五种状态,驱动着进度条颜色和进度百分比的动态计算。item字段则承载工单详情描述,如"3号荫房·第6遍髹涂·阴干中”,这种将工序、遍数、环境信息融合在一条文本中的做法,兼顾了信息密度和可读性。GearItem是原料模型,包含名称、描述和emoji图标,用于原料库列表和工坊速购区。值得注意的是,四个模型都使用了number类型的id作为唯一标识,这是因为ArkTS的ForEach组件要求keyGenerator返回字符串,所以在渲染时统一调用.id.toString()即可。

四、辅助接口定义与图表数据结构

在这里插入图片描述

除了四个核心@Observed类之外,应用还定义了一系列interface用于图表和课程等辅助场景。interface在ArkTS中是纯类型声明,不参与运行时,使用它们可以在不创建类实例的情况下定义数据形状,适合用于静态常量数组和函数参数类型约束。

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

interface MatChartItem {
  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用于每日交付件数柱状图,仅含标签和数值两个最简字段;MatChartItem额外增加了color字段,因为原料构成横向条形图需要为每类原料(生漆、桐油、金箔、螺钿、蛋壳)指定独立颜色;CourseItem包含课程标题、周次、进度和进度条颜色四个字段,用于课堂页面的课程进度列表;PlayTypeItem则用于首页玩法四格,hot布尔字段决定是否在卡片上显示"热门"角标。这种"一个场景一个接口"的设计哲学,使得每个数据结构都保持最小化,避免了过度设计带来的字段冗余。

五、静态数据数组的初始化与业务语义

在这里插入图片描述

在应用启动时,需要预填充一批初始数据用于首屏渲染。这些数据以模块级常量数组的形式声明,在组件中通过@State引用后即可参与响应式更新。下面展示几个关键的数据数组。

const POST_LIST: PostItem[] = [
  new PostItem(1, '脱胎老漆匠', '🏺', '犀皮漆茶盏第三遍髹涂,纹理开始出来了。', '4分钟前', 128),
  new PostItem(2, '金缮小师妹', '✨', '青花碎碗金缮完,裂缝变金线,绝了。', '16分钟前', 102),
  new PostItem(3, '荫房守夜人', '🌙', '今夜温湿双控,3号荫房全部过夜稳了。', '32分钟前', 93),
  new PostItem(4, '漆语主理人', '🖌️', '本周新到龙尾石老漆一批,排队缩短一半。', '1小时前', 85),
  new PostItem(5, '螺钿镶嵌控', '🐚', '夜光贝薄片切到 0.3mm,镊子夹到眼瞎。', '2小时前', 76),
  new PostItem(6, '打磨强迫症', '🔨', '800目到3000目,水磨是真的治愈。', '4小时前', 64),
  new PostItem(7, '晒器小能手', '📷', '午后阳光下的脱胎花瓶,质感像古董。', '6小时前', 55),
  new PostItem(8, '亲子髹涂团', '👨‍👩‍👦', '孩子画的漆画杯垫,我帮她髹了透明漆。', '昨天', 48)
];

POST_LIST数组是漆友社区的动态流,8条数据覆盖了脱胎、犀皮、金缮、螺钿、打磨、晒器、亲子髹涂等多种工艺方向,每条动态的昵称都富有角色感——"脱胎老漆匠"代表资深匠人、"金缮小师妹"代表年轻女性匠人、"荫房守夜人"代表生产管理者、"亲子髹涂团"代表体验用户群体。这种角色分布设计确保了社区内容的多样性和真实感。在组件内部,postList状态通过this.postList = POST_LIST完成初始化,后续用户发布新漆语时会通过unshift插入到数组头部,触发ForEach重新渲染。

const LACQUER_LIST: LacquerItem[] = [
  new LacquerItem(1, '脱胎朱红赏瓶 · 280mm', '八遍髹涂 · 推光', '¥1280/只', '典藏', 96),
  new LacquerItem(2, '犀皮漆茶盏 · 对杯', '变涂 · 金箔贴', '¥880/对', '限定', 91),
  new LacquerItem(3, '螺钿镶嵌首饰盒', '夜光贝 · 手切', '¥1580/只', '人气', 87),
  new LacquerItem(4, '金缮青瓷碗 · 修复', '大漆金粉 · 可用', '¥320/只', '手作', 82),
  new LacquerItem(5, '漆画屏风 · 四扇', '蛋壳镶嵌 · 手绘', '¥3680/组', '新品', 77),
  new LacquerItem(6, '朱黑漆筷 · 十双', '食用漆 · 安全', '¥189/盒', '入门', 70),
  new LacquerItem(7, '脱胎佛像 · 150mm', '麻布胎 · 贴金', '¥980/尊', '典藏', 84),
  new LacquerItem(8, '漆砂砚 · 随形', '漆砂 · 推光', '¥680/方', '手作', 58)
];

LACQUER_LIST是漆器橱窗的商品数据,8件漆器涵盖了赏瓶、茶盏、首饰盒、修复碗、屏风、漆筷、佛像和砚台等多种品类。每件商品的craft字段都精确描述了其核心工艺——"八遍髹涂·推光"说明赏瓶经过了八遍上漆和推光收尾,"变涂·金箔贴"说明茶盏使用了变涂技法和金箔装饰。level字段的六个取值(典藏、限定、人气、手作、新品、入门)将在levelColor函数中映射为从生漆褐到贴金色的渐变色系,为橱窗标签赋予视觉层级。hot字段表示关注度数值,在橱窗卡片中以横向进度条形式可视化呈现,数值越高条越长。

工单和原料数据数组同样遵循类似的业务语义设计。CRAFT_LIST的8条工单覆盖了从"待开工"到"已验收"的完整生命周期,GEAR_LIST的8条原料从生漆、桐油到金箔、夜光贝再到砂纸组、推光棉,构成了一套完整的大漆工具体系。此外还有图表数据WEEK_CHART(周一到周日的交付件数)、MAT_CHART(五类原料的消耗占比)、TYPE_LIST(四种玩法类型)和COURSE_LIST(五周课程进度),它们共同构成了首页精选Tab的丰富数据底座。

六、纯函数工具层的函数式设计

在这里插入图片描述

在ArkTS中,UI组件内的逻辑应当尽量精简,而将可复用的计算逻辑抽取为顶层纯函数。纯函数无副作用、输入决定输出,非常适合用于颜色映射、尺寸计算和动画坐标推导。本应用在模块顶层定义了一组纯函数,构成了整个应用的工具层。

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

function ccColor(v: number): string {
  if (v > 11) {
    return '#B83A26';
  } else if (v > 7) {
    return '#C9A84C';
  }
  return '#5C3D2E';
}

function sweepX(tick: number): number {
  return (tick * 9) % 600;
}

function sweepY(i: number): number {
  return 120 + i * 200;
}

function sweepA(tick: number): number {
  return 0.22 + (tick % 4) * 0.11;
}

barH函数将数值映射为柱状图高度像素值,基准最大值118像素对应max参数,通过Math.round四舍五入确保渲染像素为整数。ccColor函数根据交付件数返回对应颜色——超过11件用漆红、超过7件用贴金、其余用生漆褐,这种三档色阶设计让柱状图在视觉上自然传达"繁忙程度"的信息。sweepXsweepYsweepA三个函数服务于金光横扫特效——sweepX根据tick值计算横向偏移,每次tick增加9像素,取模600实现循环横扫;sweepY根据索引i计算纵向Y坐标,三条光带分布在120、320、520三个高度;sweepA根据tick计算透明度,在0.22到0.55之间波动,使光带呈现呼吸闪烁效果。

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

function dropY(tick: number, i: number): number {
  return 660 - ((tick * 8 + i * 217) % 620);
}

function dropA(tick: number, i: number): number {
  if ((tick + i * 2) % 4 === 0) {
    return 0.85;
  }
  return 0.2;
}

function statusColor(s: string): string {
  if (s === '已验收') {
    return '#6FAF8F';
  } else if (s === '髹漆中') {
    return '#B83A26';
  } else if (s === '荫干中') {
    return '#C9A84C';
  } else if (s === '打磨中') {
    return '#5C3D2E';
  }
  return '#D9973B';
}

dropXdropYdropA服务于漆滴飘落特效,通过(tick * n + i * m) % range的取模运算生成伪随机轨迹——tick递增驱动运动,i区分不同漆滴,251和217这两个质数系数确保各漆滴的轨迹不会同步重叠。dropA通过(tick + i * 2) % 4 === 0判断让每四个tick中有一次高亮闪烁,模拟漆滴反光效果。statusColor函数是工单状态到颜色的映射器,已验收绿色、髹漆中红色、荫干中金色、打磨中褐色、待开工橙色,这五种颜色在整个应用中保持一致语义。此外还有statusProgress(状态到进度百分比映射)、lacquerTagColor(漆器名称到标签色的智能匹配)、levelColor(级别到标签色的映射)等函数,它们共同构成了应用的"业务规则引擎"。

七、组件状态定义与生命周期管理

在这里插入图片描述

@Entry @Component struct PageLacquerWare是应用的入口组件。它定义了丰富的状态变量来管理Tab切换、弹框开关、编辑索引、表单内容和定时器等多个维度的状态。

@Entry
@Component
struct PageLacquerWare {
  @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 coatOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'task'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State coatItem: string = ''
  @State coatLayer: string = ''
  @State postList: PostItem[] = POST_LIST
  @State craftList: CraftItem[] = CRAFT_LIST
  @State fxTimer: number = -1

状态变量分为四组:第一组是导航状态——mainTab控制底部四大Tab(0首页/1橱窗/2课堂/3我的),subTab控制首页内七个内容Tab(0精选到6原料库);第二组是动画状态——tick是全局时钟计数器,每90毫秒递增1,驱动所有特效函数,glow是0/1交替的闪烁开关;第三组是弹框状态——四个布尔变量addOpen/editOpen/delOpen/coatOpen分别控制"发漆语"“编辑工单”“取消工单·退课”"髹漆下单"四个弹框的显隐;第四组是表单状态——editIdx记录被编辑的工单索引,editName/editStyle/editNote是编辑表单的三个输入值,addTitle/addContent是发布表单的输入值,coatItem/coatLayer是髹漆下单表单的输入值。两个@Observed数组postListcraftList是可变数据源,通过@State引用后变化即触发UI刷新。fxTimer存储定时器ID用于销毁时清理。

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

aboutToAppearaboutToDisappear是ArkTS组件的两个关键生命周期回调。aboutToAppear在组件创建后、build执行前调用,这里使用setInterval启动一个90毫秒间隔的定时器,每次回调将tick加1、glow在0和1间切换。这两个状态变化会触发fxLayer特效层的ForEach重新计算坐标和透明度,从而产生金光横扫和漆滴飘落的动态效果。aboutToDisappear在组件销毁前调用,检查定时器ID是否有效后调用clearInterval清理定时器,防止组件销毁后定时器继续运行导致的内存泄漏。这种"创建时启动、销毁时清理"的模式是ArkTS中管理定时资源的标准范式。

八、弹框操作方法与数据增删改逻辑

在这里插入图片描述

应用提供了四个弹框操作——发布漆语(新增)、编辑工单(修改)、取消工单/退课(删除)、髹漆下单(委托)。每个操作都由一对方法构成:openXxx负责打开弹框并初始化表单数据,doXxx负责执行业务逻辑并关闭弹框。

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

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

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

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

  openCoat(): void {
    this.coatItem = '';
    this.coatLayer = '';
    this.coatOpen = true;
  }

五个open方法的设计模式高度一致:先清空或回填表单字段,再设置弹框状态为true。其中openEdit最值得分析——它接收工单索引参数,先通过index >= 0 && index < this.craftList.length进行边界保护,防止越界访问,然后将对应工单的name、status、item三个字段回填到编辑表单中,实现"编辑即回显"的用户体验。openDelAopenDelB共享同一个删除弹框,通过delTarget区分删除对象——'task'表示取消首个工单,'course'表示退课,这种"一框两用"的设计减少了弹框数量。openCoat用于髹漆委托下单,清空两个输入字段后打开弹框。

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

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

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

  doCoat(): void {
    if (this.coatItem.length > 0) {
      this.postList.unshift(new PostItem(998, '我的漆语', '🏺', `已提交髹漆委托:${this.coatItem}`, '刚刚', 0));
    }
    this.coatOpen = false;
  }

四个do方法分别执行增、改、删、委托四种操作。doAdd在标题非空时调用postList.unshift在数组头部插入新动态,id固定为999,头像使用🖌️emoji,时间为"刚刚",点赞数从0开始——插入头部确保新动态出现在列表最上方。doEdit使用splice(index, 1, newItem)实现原地替换——保留原始id但更新name、status、item三个字段,进度固定为40,这种设计模拟了"编辑后重置进度"的工坊业务逻辑。doDel根据delTarget决定删除哪个数组的首元素,splice(0, 1)精确移除索引0的元素。doCoat将髹漆委托信息拼装成一条新动态插入社区流,使用模板字符串已提交髹漆委托:${this.coatItem}生成动态正文。所有do方法的最后一行都是将对应的弹框状态设为false,实现"操作完成即关闭"的流畅体验。

九、fxLayer动画特效层的@Builder实现

特效层是应用视觉差异化的重要环节。在大漆工坊中,特效设计为"金光横扫 + 漆滴飘落"——三道金光从左向右循环横扫模拟贴金时的金箔光泽,六滴漆液在屏幕上随机飘落模拟髹涂时的漆滴飞溅。这套特效通过一个@Builder方法实现,位于底层不遮挡用户点击。

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2], (i: number) => {
        Column()
          .width(60)
          .height(3)
          .borderRadius(2)
          .backgroundColor(COLORS.accent)
          .opacity(sweepA(this.tick))
          .translate({ x: sweepX(this.tick), y: sweepY(i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
        Column()
          .width(4)
          .height(4)
          .borderRadius(2)
          .backgroundColor(i % 2 === 0 ? COLORS.primary : COLORS.urushi)
          .opacity(dropA(this.tick, i))
          .translate({ x: dropX(this.tick, i), y: dropY(this.tick, i) })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

这段代码是特效层的完整实现。外层使用Stack容器铺满全屏,alignContent: Alignment.TopStart设置子元素从左上角开始定位。第一个ForEach渲染3条金光——每条是60像素宽、3像素高的细条,使用COLORS.accent贴金色,透明度由sweepA(this.tick)计算(0.22到0.55之间波动),位置由sweepXsweepY计算。由于this.tick每90毫秒递增1,sweepX返回值在0到600之间循环递增,金光便从左向右横扫,到600后跳回0重新开始,三条光带分布在不同的Y坐标上形成层次感。

第二个ForEach渲染6个漆滴——每个是4像素的正方形小点,颜色交替使用COLORS.primary漆红和COLORS.urushi生漆褐,透明度由dropA计算,位置由dropXdropY计算。dropXdropY使用了251和217两个质数系数配合tick和i做取模运算,生成看似随机但确定性的运动轨迹。dropY的计算公式660 - ((tick * 8 + i * 217) % 620)让漆滴从底部向上飘起,模拟漆液飞溅的物理感。最关键的是容器的.hitTestBehavior(HitTestMode.None)——这一属性让特效层完全透传点击事件,即使用户看到特效在屏幕上运动,点击下方的内容区域也不会被特效层拦截。这种"视觉在场、交互缺席"的设计是ArkTS中实现装饰性特效的标准做法。

十、头部header构建器与信息卡设计

头部区域是用户进入首页后看到的第一屏,需要承载搜索、进度、教学和数据四个层次的信息。通过@Builder方法将头部封装为独立单元,既保证了代码的可读性,又使得头部可以在不同Tab中复用。

  @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嵌套Text实现——放大镜emoji加灰色占位提示文字,外层Row使用layoutWeight(1)占据剩余空间,圆角18和白色背景模拟搜索框的视觉效果。购物车图标使用Stack容器叠加——底层是大号购物车emoji,右上角通过alignContent: Alignment.TopEnd叠加一个红色数字角标"3",角标使用translate({ x: 6, y: -4 })略微偏移到图标右上角外侧,模拟原生Badge效果。这种用Stack+translate实现角标的技巧在ArkTS中非常实用。

      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text('3号荫房 · 朱红底漆第6遍')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('温湿双控 · 25℃/80% · 阴干中 · 剩 14 小时')
            .fontSize(10)
            .fontColor('#F5E4E0')
          Row({ space: 6 }) {
            Text('荫干')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.accent)
              .borderRadius(6)
            Text('本周排产 · 余 6 档')
              .fontSize(9)
              .fontColor('#F5E4E0')
          }
        }
        .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.openCoat();
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.primary)
      .borderRadius(16)

头部第二块是荫房进度卡,这是整个头部最核心的信息区。卡片使用COLORS.primary漆红作为背景色,文字用白色和浅红色(#F5E4E0),形成强烈的红金对比视觉冲击。左侧Column显示三行信息——标题"3号荫房·朱红底漆第6遍"精确到荫房编号和遍数,副标题"温湿双控·25℃/80%·阴干中·剩14小时"融合了温度、湿度、状态和剩余时间四个维度的实时数据。底部一行是两个标签——"荫干"使用金色底深红字,"本周排产·余6档"用浅红色文字提示排产余量。右侧是"髹漆下单"按钮,使用金色底白字,点击触发openCoat()打开髹漆委托弹框。整个卡片通过borderRadius(16)padding(14)营造圆润饱满的卡片质感。

头部还包含匠师体验横幅和数据四格两个模块。匠师体验横幅以白色卡片承载,推广"首次髹漆免费"的新手教学活动;数据四格以四个等宽Column展示在线荫房数、本周交付件数、漆膜厚度和漆币余额四项核心指标,每格使用不同颜色(primary、accent、urushi、warning)区分数据维度。整个header方法通过Column({ space: 10 })统一管理间距,构建出一个层次分明、信息丰富的首屏头部。

十一、subNav内容Tab导航的漆面流挂式设计

首页的七个内容Tab采用横向滚动的Sub Nav形式,这是社区电商类应用的主流导航模式。在大漆场景中,这七个Tab被命名为"精选、漆器橱窗、我的工单、髹漆工坊、大漆课堂、漆友圈、原料库",每个Tab对应一个独立的页面构建器。

  @Builder
  subNav() {
    Scroll() {
      Row({ space: 10 }) {
        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)
            Column()
              .width(2)
              .height(this.subTab === idx ? 10 : 3)
              .borderRadius(1)
              .backgroundColor(this.subTab === idx ? COLORS.accent : COLORS.border)
          }
          .padding({ left: 10, right: 10, 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 })
  }

subNav的核心设计是"选中态视觉反馈"——通过三元运算符this.subTab === idx ?在四个维度上区分选中与未选中状态:字号(13 vs 11)、字重(Bold vs Normal)、颜色(primaryDark深红 vs textSecondary灰褐)、底部指示条(高10px金色 vs 高3px浅灰)。当用户点击某个Tab时,onClick回调将this.subTab设为对应索引,触发ForEach重新渲染,所有Tab的视觉状态立即更新。外层使用Scroll容器并设置scrollable(ScrollDirection.Horizontal)实现横向滚动,scrollBar(BarState.Off)隐藏滚动条保持视觉整洁。这种"漆面流挂式"的Tab设计灵感来自大漆髹涂时漆液沿器面自然流淌的视觉形态——选中态的金色长指示条就像一道漆液流挂,未选中态的灰色短条则是干涸的漆痕。

十二、pageFeatured精选页面的多模块组合

精选页面是首页默认展示的内容区,它融合了玩法四格、柱状图、原料构成、人气橱窗和漆友动态五个模块,通过Column({ space: 12 })统一管理模块间距,构建出一个信息密度高但层次清晰的聚合页面。

  @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)渲染四种玩法类型——脱胎漆器、金缮修复、螺钿镶嵌、漆友市集。每个卡片包含emoji图标(24号字)、名称(11号加粗)、描述(9号灰色,设置maxLines(1)textOverflow(Ellipsis)确保单行省略)和热门标签(it.hot为true时显示红色"热门",否则显示空格占位)。四个卡片通过layoutWeight(1)等分宽度,白色背景圆角12呈现卡片质感。这种四格布局是移动端入口导航的经典模式,用户可以快速识别和点击自己感兴趣的玩法方向。

柱状图模块和原料构成模块是精选页面的数据可视化部分。柱状图使用ForEach(WEEK_CHART)渲染七根柱子,每根柱子是一个Column包含柱体、数值和标签三层,柱体高度由barH(it.value, 18)计算,颜色由ccColor(it.value)映射,底部附有"周末交付件数占比59%"的总结行。原料构成则使用横向条形图,ForEach(MAT_CHART)渲染五条原料(生漆42%、桐油18%、金箔16%、螺钿14%、蛋壳10%),每条使用Stack叠加灰色底条和彩色进度条,进度条宽度设为${it.value}%,配合右侧百分比数值形成直观的占比可视化。人气橱窗模块采用双列布局,通过if (idx % 2 === 0)判断将相邻两个漆器配对成一行,每张卡片包含emoji图标、级别标签、名称和价格关注度信息。漆友动态模块取postList.slice(0, 3)展示前三条动态,每条以头像加内容文本的紧凑卡片形式呈现,右上方"发漆语"按钮点击触发openAdd()

十三、pageMarket橱窗页与pageCrafts工单页

橱窗页面以列表形式展示全部漆器商品,与精选页面的双列橱窗不同,这里采用单列详展布局,每件漆器展示更完整的信息包括工艺标签、价格、关注度进度条和加购按钮。

  @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(LACQUER_LIST, (it: LacquerItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(lacquerTagColor(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%')

橱窗页面每张商品卡片左侧有一条4像素宽的彩色标签条,颜色由lacquerTagColor(it.name)根据商品名称中的关键词智能匹配——包含"脱胎"或"佛像"返回漆红,包含"犀皮"或"螺钿"返回金色,包含"金缮"或"漆画"返回褐色。这种通过字符串关键词匹配颜色的设计让每件漆器都获得与品类语义一致的标签色,提升了视觉识别效率。卡片右侧的"加入购物车"按钮点击触发openCoat()打开髹漆委托弹框——在大漆场景中,购买漆器不仅是下单,还需要与匠师沟通髹涂遍数、底胎情况等定制参数,因此购物车按钮直接打开了髹漆下单弹框而非传统购物车流程。

工单页面pageCrafts以列表形式展示this.craftList,每张工单卡片包含工单名称、状态标签、详情描述、进度条和编辑按钮。状态标签颜色由statusColor(it.status)映射,进度条进度由statusProgress(it.status)映射——如"髹漆中"对应50%进度和漆红色,"荫干中"对应72%和金色。每张卡片底部的"编辑工单"按钮点击触发openEdit(idx)打开编辑弹框,实现了工单列表与编辑弹框的索引关联。

十四、pageWorkshop髹漆工坊与pageCourse大漆课堂

髹漆工坊页面是大漆工坊的核心功能区,它包含实时数据监控、人气漆器排行和原料速购三个模块,面向工坊主理人提供生产管理能力。

  @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('25℃')
            .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('80%')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        // ... 今日髹涂 6.5h、合格率 97.8%
      }
      .width('100%')

工坊页面的数据四格展示荫房温度(25℃红色)、荫房湿度(80%金色)、今日髹涂时长(6.5h褐色)和合格率(97.8%绿色)四项实时指标。这些数据精确对应大漆工艺的关键控制参数——生漆固化需要25℃左右的温度和75%-85%的湿度环境,髹涂时长反映当日产能,合格率反映质量控制水平。人气排行区取LACQUER_LIST.slice(0, 5)展示前五名漆器,排名前三使用COLORS.primary红色编号高亮,每项包含名称、工艺价格和关注度数值。原料速购区以双列布局展示GEAR_LIST中的八种原料,每种原料卡片包含大号emoji图标、名称、描述和"加购"链接。

大漆课堂页面pageCourse使用ForEach(COURSE_LIST)渲染五周课程进度,每门课程包含标题、周次、进度条和进度百分比。进度条颜色使用课程自身的it.color字段,从第一周的漆红100%到第五周的金色11%,形成从已完成到未开始的渐变可视化。页面底部的退课按钮使用了一个巧妙的技巧——Button组件本身不包含文字,而是通过Text组件配合负margin叠加在按钮上方,实现文字居中效果:

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

这种Button+Text叠加的模式虽然不如直接使用Button('退课')简洁,但在某些ArkTS版本中可以更精确地控制文字位置和样式。退课按钮点击触发openDelB(),设置delTarget = 'course'后打开删除确认弹框。

十五、pageCircle漆友圈与pageGear原料库

漆友圈页面是社区动态的全量展示区,与精选页面中仅展示前三条不同,这里通过ForEach(this.postList)渲染全部动态,每条动态的卡片更宽更大——头像emoji使用26号字,正文使用13号字,底部增加"🖌️点赞数·💬回复"的互动指标行。页面顶部的"发漆语"按钮点击触发openAdd(),用户发布的新动态会通过unshift插入到列表头部。

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

原料库页面pageGear顶部是三个大分类入口——生漆区(🫗)、贴金区(✨)、螺钿区(🐚),每个入口使用28号大emoji图标,下方是分类名称和子类型描述。中部是ForEach(GEAR_LIST)渲染的八种原料详细列表,每行包含20号emoji图标、名称描述和"加购"按钮。页面底部的"取消首个工单"按钮点击触发openDelA(),设置delTarget = 'task'后打开删除确认弹框,用于取消排在首位的工单。

十六、pageMine个人中心与用户画像

个人中心页面展示用户的完整画像,包含会员信息卡、工单档案和漆语档案三个模块。

  @Builder
  pageMine() {
    Column({ space: 12 }) {
      Row({ space: 12 }) {
        Text('🏺')
          .fontSize(40)
        Column({ space: 4 }) {
          Text('脱胎老漆匠')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('金层会员 · 交付 42 件 · 髹涂 128 遍 · 陪伴 200 天')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

会员信息卡使用40号大emoji头像配合18号昵称和11号会员描述,描述文本"金层会员·交付42件·髹涂128遍·陪伴200天"融合了会员等级、交付件数、髹涂遍数和陪伴天数四个维度的用户数据,这种"一行四数据"的紧凑设计是个人中心页面的经典信息呈现方式。工单档案区通过ForEach(this.craftList.slice(0, 3))展示前三条工单的名称和状态,状态颜色由statusColor映射。漆语档案区同样取前三条动态展示昵称和时间。两个档案卡片顶部都有标题行,右侧以COLORS.primary红色显示总数统计(如"8单"“8条”),让用户一目了然自己的数据资产规模。

十七、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.coatOpen = false;
        })
    }
    .width('100%')
    .height('100%')
  }

modalOverlay是遮罩层构建器,使用一个铺满全屏的Column,背景色为黑色透明度0.6,点击时将四个弹框状态全部设为false实现"点击遮罩关闭弹框"的通用交互。这种统一遮罩层的设计确保了所有弹框的视觉一致性和交互一致性。

发布漆语弹框addModalBody包含标题、TextInput(一句话记录)、TextArea(补充细节)和取消/发布双按钮。编辑工单弹框editModalBody包含工单名称、状态、参数备注三个输入框。删除确认弹框delModalBody根据delTarget动态显示不同的标题和提示文本——'task'显示"取消首个工单"和"将取消首个工单并回收已用生漆",'course'显示"退出大漆课堂"和"将退出首门进行中的课程"。髹漆下单弹框coatModalBody包含漆器名称、髹涂遍数两个输入框和一个提示文本"匠师审核底胎后30分钟内回电报价·含荫房与推光",底部使用金色(COLORS.accent)按钮而非红色,体现了委托类操作的特殊性。

  @Builder
  coatModalBody() {
    Column({ space: 12 }) {
      Text('髹漆下单')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '漆器名称(如 脱胎朱红赏瓶)', text: this.coatItem })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.coatItem = v;
        })
      TextInput({ placeholder: '髹涂遍数(如 8遍推光)', text: this.coatLayer })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.coatLayer = v;
        })
      Text('匠师审核底胎后 30 分钟内回电报价 · 含荫房与推光')
        .fontSize(11)
        .fontColor(COLORS.textHint)
        .width('100%')

髹漆下单弹框的设计体现了大漆工艺的定制化特性——用户需要输入漆器名称和髹涂遍数(如"8遍推光"),系统会提示匠师审核底胎后30分钟内回电报价,并说明报价含荫房与推光服务。这种将工艺流程融入弹框提示的设计,使得应用不仅是工具,更承担了工艺教育功能。所有弹框的TextInput都通过onChange回调实时更新对应的状态变量,实现了双向数据绑定。

十八、bottomBar底部导航与mainContent主内容路由

底部导航栏是应用的主导航入口,通过四个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 })
  }

底部导航使用ForEach(NAV_LIST)渲染四个Tab项——首页(🏺)、橱窗(✨)、课堂(🖌️)、我的(👤)。选中态通过两个维度区分:图标透明度(1 vs 0.55)和标签颜色加粗(primary加粗 vs textHint常规)。导航栏使用白色背景配合顶部16像素圆角,营造出浮于内容上方的视觉效果。onClick回调将mainTab设为被点击的索引,触发主内容区的条件渲染切换。

mainContent方法根据mainTab的值路由到不同的页面组合。当mainTab === 0时,渲染header + subNav + 根据subTab路由七个内容页之一;当mainTab === 1时直接渲染pageMarket;mainTab === 2渲染pageCourse;mainTab === 3渲染pageMine。这种"if-else链式路由"虽然不如Switch优雅,但在ArkTS中是组件条件渲染的惯用模式。每个Tab的内容都包裹在Scroll容器中,设置scrollable(ScrollDirection.Vertical)scrollBar(BarState.Off)实现纵向滚动且隐藏滚动条。

十九、build主架构与弹框叠加渲染

build方法是组件的入口构建器,它将背景层、特效层、主内容层和弹框层通过Stack叠加组装成最终的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%')
      }
      // ... editOpen / delOpen / coatOpen 同理
    }
    .width('100%')
    .height('100%')
  }

build方法的最外层是一个Stack,内部按顺序叠加了多层内容:第一层是全屏背景色Column(COLORS.bg暖米色);第二层是this.fxLayer()特效层,由于设置了hitTestBehavior(HitTestMode.None),它不会拦截下方层的点击事件;第三层是主内容Column,包含mainContent()bottomBar(),占据全屏高度;第四层及之后是四个弹框层,通过if条件控制显隐。每个弹框层都是一个Stack,内含modalOverlay()遮罩层和居中的弹框内容Column,弹框内容宽度88%、最大高度80%、圆角16、zIndex设为999确保浮于最上层。当addOpen等状态变为true时,对应的if分支被激活,弹框层渲染出来覆盖在主内容上方;变为false时弹框层消失,露出下方主内容。这种"条件渲染+Stack叠加"的模式是ArkTS中实现弹框的标准做法,无需引入额外的弹框管理框架即可实现复杂的弹框交互。

二十、架构流程图

mainTab=0

mainTab=1

mainTab=2

mainTab=3

subTab=0

subTab=1

subTab=2

subTab=3

subTab=4

subTab=5

subTab=6

addOpen

editOpen

delOpen

coatOpen

build 入口

背景层 COLORS.bg

fxLayer 特效层

mainContent 主内容层

弹框层 条件渲染

金光横扫 ForEach 0,1,2

漆滴飘落 ForEach 0-5

hitTestBehavior None 透传点击

header 头部

mainTab 路由

bottomBar 底部导航

header + subNav + 7内容页

pageMarket 橱窗

pageCourse 课堂

pageMine 我的

pageFeatured 精选

pageMarket 橱窗

pageCrafts 工单

pageWorkshop 工坊

pageCourse 课堂

pageCircle 漆友圈

pageGear 原料库

addModalBody 发漆语

editModalBody 编辑工单

delModalBody 取消/退课

coatModalBody 髹漆下单

doAdd: postList.unshift

doEdit: craftList.splice 替换

doDel: splice 删除首元素

doCoat: postList.unshift 委托

上图清晰地展示了应用从build入口到各功能模块的完整架构流转。背景层、特效层、主内容层和弹框层在Stack中自下而上叠加,特效层通过hitTestBehavior(None)实现点击透传。主内容层内部通过mainTab实现四大Tab路由,首页Tab内部再通过subTab实现七个内容页的二级路由。四个弹框层各自通过独立的状态变量控制显隐,操作完成后通过do方法执行数据变更并自动关闭。

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

下表对应用中四个核心数据模型进行对比,帮助开发者理解各模型的字段构成、业务语义和适用场景。

模型装饰器核心字段业务语义关联状态变量适用页面关键纯函数
PostItem@Observedid, nick, avatar, text, time, likes漆友社区动态,支持发布新增和删除postListpageFeatured, pageCircle, pageMine无(直接渲染)
LacquerItem@Observedid, name, craft, price, level, hot漆器商品,含工艺描述和级别热度无(静态常量)pageMarket, pageFeatured, pageWorkshoplacquerTagColor, levelColor
CraftItem@Observedid, name, item, status, progress生产工单,状态驱动进度和颜色craftListpageCrafts, pageMinestatusColor, statusProgress
GearItem@Observedid, name, desc, icon原料物资,用于工坊速购和原料库无(静态常量)pageWorkshop, pageGear无(直接渲染)

下表对比四个弹框组件的设计差异,展示了"同构不同义"的弹框架构模式。

弹框触发方法状态变量表单字段提交方法数据操作按钮颜色
发漆语openAddaddOpenaddTitle, addContentdoAddpostList.unshiftprimary 漆红
编辑工单openEditeditOpeneditName, editStyle, editNotedoEditcraftList.splice 替换primary 漆红
取消/退课openDelA/BdelOpendelTargetdoDelsplice 删除首元素danger 红
髹漆下单openCoatcoatOpencoatItem, coatLayerdoCoatpostList.unshift 委托accent 金

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

const COLORS: ColorPalette = {
  primary: '#B83A26',
  primaryLight: '#F5E4E0',
  primaryDark: '#8A2C1D',
  accent: '#C9A84C',
  accentLight: '#F5EDD8',
  bg: '#F8F3E8',
  cardBg: '#FFFFFF',
  textPrimary: '#3A2A1E',
  textSecondary: '#7A6A5E',
  textHint: '#B5A89E',
  border: '#EBE0D2',
  success: '#6FAF8F',
  warning: '#D9973B',
  danger: '#C0503F',
  white: '#FFFFFF',
  urushi: '#5C3D2E'
};

// ============ 漆友动态数据模型 ============
@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 LacquerItem {
  id: number = 0
  name: string = ''
  craft: string = ''
  price: string = ''
  level: string = ''
  hot: number = 0
  constructor(id: number, name: string, craft: string, price: string, level: string, hot: number) {
    this.id = id; this.name = name; this.craft = craft; this.price = price
    this.level = level; this.hot = hot
  }
}

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

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

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

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

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

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

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '脱胎老漆匠', '🏺', '犀皮漆茶盏第三遍髹涂,纹理开始出来了。', '4分钟前', 128),
  new PostItem(2, '金缮小师妹', '✨', '青花碎碗金缮完,裂缝变金线,绝了。', '16分钟前', 102),
  new PostItem(3, '荫房守夜人', '🌙', '今夜温湿双控,3号荫房全部过夜稳了。', '32分钟前', 93),
  new PostItem(4, '漆语主理人', '🖌️', '本周新到龙尾石老漆一批,排队缩短一半。', '1小时前', 85),
  new PostItem(5, '螺钿镶嵌控', '🐚', '夜光贝薄片切到 0.3mm,镊子夹到眼瞎。', '2小时前', 76),
  new PostItem(6, '打磨强迫症', '🔨', '800目到3000目,水磨是真的治愈。', '4小时前', 64),
  new PostItem(7, '晒器小能手', '📷', '午后阳光下的脱胎花瓶,质感像古董。', '6小时前', 55),
  new PostItem(8, '亲子髹涂团', '👨‍👩‍👦', '孩子画的漆画杯垫,我帮她髹了透明漆。', '昨天', 48)
];

const LACQUER_LIST: LacquerItem[] = [
  new LacquerItem(1, '脱胎朱红赏瓶 · 280mm', '八遍髹涂 · 推光', '¥1280/只', '典藏', 96),
  new LacquerItem(2, '犀皮漆茶盏 · 对杯', '变涂 · 金箔贴', '¥880/对', '限定', 91),
  new LacquerItem(3, '螺钿镶嵌首饰盒', '夜光贝 · 手切', '¥1580/只', '人气', 87),
  new LacquerItem(4, '金缮青瓷碗 · 修复', '大漆金粉 · 可用', '¥320/只', '手作', 82),
  new LacquerItem(5, '漆画屏风 · 四扇', '蛋壳镶嵌 · 手绘', '¥3680/组', '新品', 77),
  new LacquerItem(6, '朱黑漆筷 · 十双', '食用漆 · 安全', '¥189/盒', '入门', 70),
  new LacquerItem(7, '脱胎佛像 · 150mm', '麻布胎 · 贴金', '¥980/尊', '典藏', 84),
  new LacquerItem(8, '漆砂砚 · 随形', '漆砂 · 推光', '¥680/方', '手作', 58)
];

const CRAFT_LIST: CraftItem[] = [
  new CraftItem(1, '脱胎朱红赏瓶', '3号荫房 · 第6遍髹涂 · 阴干中', '荫干中', 72),
  new CraftItem(2, '犀皮漆茶盏', '髹涂中 · 第4遍色漆', '髹漆中', 45),
  new CraftItem(3, '螺钿首饰盒', '打磨中 · 2000目水磨', '打磨中', 88),
  new CraftItem(4, '金缮青瓷碗', '已验收 · 金线固化和格', '已验收', 100),
  new CraftItem(5, '漆画屏风', '选石中 · 蛋壳拼贴稿', '待开工', 12),
  new CraftItem(6, '脱胎佛像', '髹涂中 · 贴金准备', '髹漆中', 58),
  new CraftItem(7, '朱黑漆筷×10', '已完成 · 食用漆检测', '已验收', 100),
  new CraftItem(8, '漆砂砚', '打磨中 · 3000目收光', '打磨中', 92)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '生漆 · 500g', '过滤 · 头道漆', '🫗'),
  new GearItem(2, '桐油 · 300g', '熬制 · 亮油', '🫙'),
  new GearItem(3, '金箔 · 100张', '真金 · 手贴', '✨'),
  new GearItem(4, '夜光贝 · 50g', '薄片 · 螺钿', '🐚'),
  new GearItem(5, '砂纸组 · 400-3000', '水磨 · 干磨', '📄'),
  new GearItem(6, '牛角刮刀 · 大', '调漆 · 髹涂', '🥄'),
  new GearItem(7, '蛋壳 · 拼贴用', '白·褐·混色', '🥚'),
  new GearItem(8, '推光棉 · 手套', '鹿角霜 · 推光', '🧤')
];

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

const MAT_CHART: MatChartItem[] = [
  { label: '生漆', value: 42, color: '#B83A26' },
  { label: '桐油', value: 18, color: '#C9A84C' },
  { label: '金箔', value: 16, color: '#D9973B' },
  { label: '螺钿', value: 14, color: '#5C3D2E' },
  { label: '蛋壳', value: 10, color: '#6FAF8F' }
];

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: '#B83A26' },
  { title: '脱胎制胎与麻布裱贴', week: '第 2 周', progress: 88, color: '#C9A84C' },
  { title: '髹涂技法与荫房控制', week: '第 3 周', progress: 62, color: '#5C3D2E' },
  { title: '推光与罩漆收尾', week: '第 4 周', progress: 34, color: '#6FAF8F' },
  { title: '螺钿与蛋壳镶嵌', week: '第 5 周', progress: 11, 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 ccColor(v: number): string {
  if (v > 11) {
    return '#B83A26';
  } else if (v > 7) {
    return '#C9A84C';
  }
  return '#5C3D2E';
}

function sweepX(tick: number): number {
  return (tick * 9) % 600;
}

function sweepY(i: number): number {
  return 120 + i * 200;
}

function sweepA(tick: number): number {
  return 0.22 + (tick % 4) * 0.11;
}

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

function dropY(tick: number, i: number): number {
  return 660 - ((tick * 8 + i * 217) % 620);
}

function dropA(tick: number, i: number): number {
  if ((tick + i * 2) % 4 === 0) {
    return 0.85;
  }
  return 0.2;
}

function statusColor(s: string): string {
  if (s === '已验收') {
    return '#6FAF8F';
  } else if (s === '髹漆中') {
    return '#B83A26';
  } else if (s === '荫干中') {
    return '#C9A84C';
  } else if (s === '打磨中') {
    return '#5C3D2E';
  }
  return '#D9973B';
}

function statusProgress(s: string): number {
  if (s === '已验收') {
    return 100;
  } else if (s === '髹漆中') {
    return 50;
  } else if (s === '荫干中') {
    return 72;
  } else if (s === '打磨中') {
    return 88;
  }
  return 12;
}

function lacquerTagColor(g: string): string {
  if (g.indexOf('脱胎') >= 0 || g.indexOf('佛像') >= 0) {
    return '#B83A26';
  } else if (g.indexOf('犀皮') >= 0 || g.indexOf('螺钿') >= 0) {
    return '#C9A84C';
  } else if (g.indexOf('金缮') >= 0 || g.indexOf('漆画') >= 0) {
    return '#5C3D2E';
  }
  return '#D9973B';
}

function levelColor(s: string): string {
  if (s === '入门') {
    return '#6FAF8F';
  } else if (s === '人气') {
    return '#B83A26';
  } else if (s === '限定') {
    return '#C9A84C';
  } else if (s === '典藏') {
    return '#5C3D2E';
  } else if (s === '新品') {
    return '#D9973B';
  }
  return '#C0503F';
}

@Entry
@Component
struct PageLacquerWare {
  @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 coatOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'task'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State coatItem: string = ''
  @State coatLayer: string = ''
  @State postList: PostItem[] = POST_LIST
  @State craftList: CraftItem[] = CRAFT_LIST
  @State fxTimer: number = -1

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

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

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

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

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

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

  openCoat(): void {
    this.coatItem = '';
    this.coatLayer = '';
    this.coatOpen = true;
  }

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

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

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

  doCoat(): void {
    if (this.coatItem.length > 0) {
      this.postList.unshift(new PostItem(998, '我的漆语', '🏺', `已提交髹漆委托:${this.coatItem}`, '刚刚', 0));
    }
    this.coatOpen = false;
  }

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2], (i: number) => {
        Column()
          .width(60)
          .height(3)
          .borderRadius(2)
          .backgroundColor(COLORS.accent)
          .opacity(sweepA(this.tick))
          .translate({ x: sweepX(this.tick), y: sweepY(i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
        Column()
          .width(4)
          .height(4)
          .borderRadius(2)
          .backgroundColor(i % 2 === 0 ? COLORS.primary : COLORS.urushi)
          .opacity(dropA(this.tick, i))
          .translate({ x: dropX(this.tick, i), y: dropY(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号荫房 · 朱红底漆第6遍')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('温湿双控 · 25℃/80% · 阴干中 · 剩 14 小时')
            .fontSize(10)
            .fontColor('#F5E4E0')
          Row({ space: 6 }) {
            Text('荫干')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.accent)
              .borderRadius(6)
            Text('本周排产 · 余 6 档')
              .fontSize(9)
              .fontColor('#F5E4E0')
          }
        }
        .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.openCoat();
          })
      }
      .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('周六下午 · 免费 30 分钟调漆指导 · 送砂纸组')
            .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.openCoat();
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 数据四格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('在线荫房')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('5 间')
            .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('32 件')
            .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('0.8mm')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.urushi)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('漆币')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('¥256')
            .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: 10 }) {
        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)
            Column()
              .width(2)
              .height(this.subTab === idx ? 10 : 3)
              .borderRadius(1)
              .backgroundColor(this.subTab === idx ? COLORS.accent : COLORS.border)
          }
          .padding({ left: 10, right: 10, 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, 18))
                .borderRadius(5)
                .backgroundColor(ccColor(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('59%')
            .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(MAT_CHART, (it: MatChartItem) => {
          Row({ space: 8 }) {
            Text(it.label)
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .width(76)
            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: MatChartItem) => 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(LACQUER_LIST, (it: LacquerItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < LACQUER_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🏺')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(LACQUER_LIST[idx].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(LACQUER_LIST[idx].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(LACQUER_LIST[idx].name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${LACQUER_LIST[idx].price} · 关注 ${LACQUER_LIST[idx].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < LACQUER_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('✨')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(LACQUER_LIST[idx + 1].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(LACQUER_LIST[idx + 1].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(LACQUER_LIST[idx + 1].name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${LACQUER_LIST[idx + 1].price} · 关注 ${LACQUER_LIST[idx + 1].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: LacquerItem) => 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(LACQUER_LIST, (it: LacquerItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(lacquerTagColor(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(lacquerTagColor(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.openCoat();
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: LacquerItem) => it.id.toString())
    }
    .width('100%')
  }

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

  @Builder
  pageWorkshop() {
    Column({ space: 10 }) {
      Text('髹漆工坊')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('荫房温度')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('25℃')
            .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('80%')
            .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('6.5 h')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.urushi)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('合格率')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('97.8%')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(LACQUER_LIST.slice(0, 5), (it: LacquerItem, 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(lacquerTagColor(it.name))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: LacquerItem) => 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('头道 · 二道')
            .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: -100, 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('金层会员 · 交付 42 件 · 髹涂 128 遍 · 陪伴 200 天')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的工单档案')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.craftList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.craftList.slice(0, 3), (it: CraftItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.status)
              .fontSize(11)
              .fontColor(statusColor(it.status))
          }
          .width('100%')
        }, (it: CraftItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的漆语')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.postList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row() {
            Text(it.nick)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.time)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }
    .width('100%')
  }

  @Builder
  modalOverlay() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(0.6)
        .onClick(() => {
          this.addOpen = false;
          this.editOpen = false;
          this.delOpen = false;
          this.coatOpen = 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 === 'task' ? '取消首个工单' : '退出大漆课堂')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(this.delTarget === 'task' ? '将取消首个工单并回收已用生漆,确认执行?' : '将退出首门进行中的课程,确认执行?')
        .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
  coatModalBody() {
    Column({ space: 12 }) {
      Text('髹漆下单')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '漆器名称(如 脱胎朱红赏瓶)', text: this.coatItem })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.coatItem = v;
        })
      TextInput({ placeholder: '髹涂遍数(如 8遍推光)', text: this.coatLayer })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.coatLayer = v;
        })
      Text('匠师审核底胎后 30 分钟内回电报价 · 含荫房与推光')
        .fontSize(11)
        .fontColor(COLORS.textHint)
        .width('100%')
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.coatOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.accent)
          .borderRadius(8)
          .onClick(() => {
            this.doCoat();
          })
        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.coatOpen) {
        Stack() {
          this.modalOverlay()
          Column() {
            this.coatModalBody()
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
  }
}


二十二、总结

在这里插入图片描述

本文以一个完整的大漆工艺工坊应用为案例,系统性地解析了基于HarmonyOS 6.1.1 ArkTS API 24开发非遗工艺类移动应用的完整技术链路。从ColorPalette色彩接口定义到@Observed数据模型建模,从静态数据数组初始化到纯函数工具层设计,从fxLayer动画特效实现到多页面@Builder组件化拆分,从弹框交互逻辑到build主架构组装,每一层都体现了ArkTS声明式UI编程的核心优势。

在色彩体系层面,应用采用"接口约束+常量注入"的模式,通过16个语义化字段构建了一套完整的漆艺色彩语言。漆红、贴金、生漆褐三色不仅还原了大漆器物的视觉质感,更在功能层面承担着状态区分和层级强调的职责。urushi字段的命名体现了对大漆文化的深度理解,使色彩体系从技术工具升级为文化载体。这种将领域语义融入代码命名的设计哲学,值得在其他非遗数字化项目中推广。

在数据模型层面,四个@Observed类精确映射了大漆工坊的四类核心实体——PostItem承载社区动态流,LacquerItem承载商品橱窗,CraftItem承载生产工单,GearItem承载原料物资。每个模型都采用"默认值初始化+构造函数赋值"的标准模式,确保对象创建时不会出现undefined属性。@Observed装饰器使得模型实例被@State引用后自动具备响应式能力,开发者无需手动调用refresh方法即可实现数据变化到UI更新的自动同步。辅助接口(WeekChartItem、MatChartItem、CourseItem、PlayTypeItem)则保持了最小化设计,为图表和列表场景提供了轻量级的数据形状约束。

在动画特效层面,fxLayer通过三层Stack叠加实现了"金光横扫+漆滴飘落"的动态视觉。三道金光以60像素宽3像素高的细条形态,通过sweepX/sweepY/sweepA三个纯函数计算坐标和透明度,在90毫秒tick驱动下从左向右循环横扫。六个漆滴以4像素小方点的形态,通过dropX/dropY/dropA计算出的伪随机轨迹在屏幕上飘落,颜色交替使用漆红和生漆褐。hitTestBehavior(HitTestMode.None)确保特效层完全透传点击事件,实现了"视觉在场、交互缺席"的装饰性特效标准模式。这种基于定时器驱动纯函数计算坐标的动画方案,无需依赖复杂的动画API即可实现流畅的动态效果。

在组件化架构层面,应用通过十余个@Builder方法将复杂UI拆分为可复用的构建单元。header承载搜索、进度卡、横幅和数据四格四个模块;subNav实现七Tab横向滚动导航;pageFeatured到pageGear七个页面构建器分别承载精选、橱窗、工单、工坊、课堂、圈子和原料库七个内容区;四个modalBody构建器实现发布、编辑、删除和委托弹框;bottomBar实现四Tab底部导航。mainContent通过if-else链式路由将mainTab和subTab映射到对应页面,build方法最终通过Stack将背景层、特效层、主内容层和弹框层叠加组装。这种"Builder拆分+条件路由+Stack叠加"的三段式架构,在保持代码可维护性的同时实现了丰富的页面层次。

Logo

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

更多推荐