一、技术背景

HarmonyOS 的声明式 UI 框架 ArkUI 是华为面向全场景智慧生活打造的统一开发范式。与传统的命令式 UI 编程不同,ArkUI 采用了类似 SwiftUI 的声明式语法,开发者只需描述界面的当前状态,框架便会自动管理 UI 的创建、更新与销毁过程。这种范式极大地简化了复杂界面的开发流程,让开发者能够将更多精力投入到业务逻辑与用户体验的打磨上。ArkUI 底层基于方舟编译器和方舟引擎,通过静态类型推导和 AOT 编译实现了接近原生的运行性能,同时支持跨设备部署,一套代码可以在手机、平板、智慧屏等多种设备上运行。在渲染管线层面,ArkUI 采用了自研的 2D/3D 渲染引擎,支持丰富的动画、特效与手势交互,为应用提供流畅顺滑的视觉体验。

ArkTS 是在 TypeScript 基础上扩展而来的编程语言,专为 HarmonyOS 应用开发而设计。它在保留 TypeScript 静态类型检查能力的同时,引入了若干面向 UI 开发的语法扩展,例如装饰器系统(@Entry、@Component、@State、@Builder、@Observed 等)以及声明式 UI 描述语法。ArkTS 通过编译期的严格类型约束,杜绝了 JavaScript 中常见的运行时类型错误,同时配合方舟编译器的高效优化,使得应用在启动速度和运行性能上都有显著提升。在 ArkTS 中,UI 组件以 struct 结构体为核心单元,通过装饰器标注其角色和生命周期,开发者可以在 struct 内部声明状态变量、构建方法和生命周期回调,形成一个高内聚低耦合的组件模型。

HarmonyOS 生态的核心优势在于其分布式能力和跨设备协同。系统层面的分布式软总线技术使得不同设备之间可以低成本地发现彼此、建立连接并协同工作。对于消费类应用而言,这意味着用户可以在手机上浏览蜡烛工坊的产品,在平板上查看更详细的配方课程,在智慧屏上展示工坊的预约日历。HarmonyOS 还提供了统一的数据管理能力和安全机制,确保用户数据在跨设备流转时的安全性和一致性。在开发工具链方面,DevEco Studio 提供了从编码、预览、调试到打包发布的全流程支持,配合 ArkTS 的热重载能力,开发者可以实时看到 UI 变更效果,大幅提升迭代效率。

@Observed 装饰器是 ArkUI 状态管理体系中的关键一环,用于标注可观察的数据模型类。当一个类被 @Observed 修饰后,该类的实例在被 @State、@Link、@ObjectLink 等状态变量引用时,其属性的变化会被框架自动追踪并触发关联 UI 的局部刷新。这种细粒度的响应式机制避免了全局重渲染带来的性能损耗,使得在复杂列表场景下(如本应用中的蜡烛作品列表、烛友动态流等),单个数据项的修改只会更新对应的 UI 片段,而不影响整个页面的其他部分。@Observed 与 @State 的配合使用,构成了 ArkUI 中"数据驱动视图"的核心架构基石,开发者只需关注数据的变化逻辑,UI 的同步更新由框架自动完成。

手工蜡烛香氛行业是一个融合了传统手工艺与现代审美的特色消费领域。蜡烛制作涉及蜡材选择(大豆蜡、蜂蜡、棕榈蜡、果冻蜡等)、香调配比(前调、中调、后调的分层设计)、温控工艺(熔点管理、冷却结晶控制)以及模具成型等多个专业环节,每个环节都有严格的技术参数和质量标准。在数字化层面,该行业面临着产品信息管理复杂(每款蜡烛关联香调配方、蜡材类型、制作状态等多维度属性)、用户社区运营需求强(烛友圈的动态分享与互动)、课程教学场景特殊(分阶段进阶课程与进度跟踪)以及线下工坊预约调度等挑战。一个优秀的行业应用需要将这些复杂的业务场景以简洁直观的界面呈现给用户,同时通过细腻的视觉设计和动态特效来传递蜡烛工艺的温度感和仪式感,这正是本应用烛语工坊所要解决的核心课题。

二、项目概览

烛语·手工蜡烛香氛工坊是一款面向手工蜡烛爱好者与从业者的综合型行业应用,涵盖了从产品展示、配方管理到社区互动、课程教学的完整业务链路。应用采用底部四主Tab的导航架构,分别为"首页"“烛坊”“课堂”“我的”,每个主Tab承载不同维度的功能职责。其中"首页"Tab最为复杂,内部进一步细分为七个内容子Tab,依次为精选、蜡烛作品、香调配方、蜡材模具、烛艺课堂、烛友圈和工坊预约,形成了一个多层级的内容分发体系。这种"主Tab+子Tab"的嵌套导航模式既保证了底部导航的简洁性,又能在首页中承载丰富的内容模块,是内容密集型应用的典型架构选择。

在视觉设计层面,应用采用了深色暖调的配色方案,以深褐色(#241812)作为主背景色,模拟蜡烛燃烧时昏暗温馨的室内氛围。主色调选用金黄色(#E8A84F)象征烛焰光芒,辅以玫粉色(#D98AA4)和浅金色(#F0C47E)作为点缀,整体色调温暖而不失精致感。应用还精心定义了一套包含十五个语义化颜色字段的配色面板(ColorPalette),涵盖主色、背景、卡片背景、文本主次色、边框色、成功/警告/危险状态色以及烛焰专用色等,确保了全应用视觉一致性。头部区域以"烛语工坊"为标题,配以蜡烛作品·香调配方·烛艺课堂的副标题描述和两个装饰性Emoji图标,形成简洁而富有辨识度的品牌识别区。

应用的交互与动态特性是其亮点所在。特效层采用了"烛焰跳动+蜡滴下落"的双重动态效果,通过定时器驱动的纯函数计算每个烛焰粒子的位置、大小、透明度以及蜡滴的下落位置和透明度,在页面底层以不可点击的方式持续渲染,营造出蜡烛工坊内烛光摇曳的氛围感。该特效层设置了 hitTestBehavior(HitTestMode.None),确保不遮挡上层内容的点击交互。在功能交互层面,应用实现了三类弹窗:新增弹窗(发布烛语)、编辑弹窗(编辑蜡烛配方)和删除弹窗(下架蜡烛/退课),通过统一的状态变量管理和模态遮罩层实现一致的交互体验。整体架构上,应用将所有UI片段以@Builder方法的形式拆分,包括页面级Builder(pageFeatured、pageCandle等)和组件级Builder(header、subNav、bottomBar等),在build方法中通过条件渲染和层级堆叠组合出完整的界面。

三、逐段代码分析

3.1 颜色体系接口定义

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

在这里插入图片描述

ColorPalette 接口定义了整个应用的配色体系骨架。在 ArkTS 中,interface 关键字用于声明纯类型描述,编译后不会生成任何运行时代码,仅作为编译期的类型约束存在。这里定义了十六个字符串类型的颜色字段,每个字段都采用语义化命名,使得任何使用处都能直观理解其用途。primary 系列代表主色及其明暗变体,accent 系列代表强调色,bg 和 cardBg 分别代表页面背景和卡片背景,textPrimary/Secondary/Hint 形成三级文本色阶,border 用于分隔线,success/warning/danger 用于状态标识,white 是纯白色,而 flame 则是专为烛焰特效设计的专用色。

这种将颜色体系抽象为接口的做法体现了设计系统思维。通过接口定义颜色的"契约",再通过常量对象实例化具体值,实现了颜色定义与使用解耦。如果未来需要支持暗色/亮色主题切换或多品牌换肤,只需提供不同的 ColorPalette 实现即可,使用方的代码无需任何修改。同时,TypeScript 的类型检查会在编译期校验所有颜色引用是否使用了合法的字段名,避免了拼写错误导致的颜色缺失问题。十六个字段的数量经过精心考量,既覆盖了应用中所有视觉场景的需求,又不至于过度膨胀导致维护负担。

3.2 COLORS 配色常量实例化

const COLORS: ColorPalette = {
  primary: '#E8A84F',
  primaryLight: '#F0C47E',
  primaryDark: '#B87A2E',
  accent: '#D98AA4',
  accentLight: '#F5E4EA',
  bg: '#241812',
  cardBg: '#32241C',
  textPrimary: '#F7EDE0',
  textSecondary: '#C0A88F',
  textHint: '#8A7260',
  border: '#4A3526',
  success: '#8FBF7E',
  warning: '#E8A84F',
  danger: '#D96A5C',
  white: '#FFFFFF',
  flame: '#F5B25C'
};

COLORS 常量是 ColorPalette 接口的具体实现,以 const 关键字声明并在模块顶层定义,保证了全应用范围内的单例性和不可变性。主色 #E8A84F 是一种温暖的金黄色,让人联想到烛焰的光芒和蜂蜡的色泽,与手工蜡烛行业高度契合。背景色 #241812 是极深的棕褐色,模拟昏暗烛光下室内的氛围色调,与主色形成明暗对比,确保内容可读性。卡片背景 #32241C 比页面背景稍亮,通过微妙的色阶差异区分内容层级。文本色采用暖白色系(#F7EDE0 至 #8A7260),与背景色形成协调的暖调氛围。

值得注意的是 success 色使用 #8FBF7E(柔和的草绿色)而非传统的纯绿色,warning 色复用主色 #E8A84F,danger 色使用 #D96A5C(暖红/珊瑚红)而非刺眼的纯红色。这些颜色选择都经过精心调校,确保在深色暖调背景下既清晰可辨又不破坏整体的温暖氛围。flame 色 #F5B25C 比主色略亮且偏橙,专门用于烛焰特效层,在深色背景上能产生明亮的跳动效果。整个配色方案充分体现了"色彩服务于行业氛围"的设计理念,每个色值都经过反复推敲以达到最佳的视觉和谐。

3.3 烛友动态数据模型 PostItem

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

PostItem 是烛友圈动态的数据模型,被 @Observed 装饰器修饰,表示该类的实例属性变化可被 ArkUI 框架自动追踪。类中定义了六个字段:id 作为唯一标识,nick 存储用户昵称,avatar 使用 Emoji 字符串作为头像(如 🕯️、🧊),text 存储动态正文内容,time 存储相对时间描述(如"9分钟前"),likes 存储点赞数。构造函数接受所有六个参数并依次赋值,采用单行紧凑写法。字段声明时均给出了默认值(0 或空字符串),这是 ArkTS 的类型安全要求——所有类属性必须在声明时初始化或在构造函数中赋值,不允许存在未初始化的可空字段。

export 关键字使得 PostItem 类可以被其他模块引用,但由于本应用是单文件结构,export 主要服务于类型系统的完整性。@Observed 的核心作用在于:当 PostItem 实例被 @State 修饰的数组(如 postList)引用时,通过 splice、unshift 等方法修改数组元素会触发框架的 diff 机制,自动更新对应的 ForEach 列表项 UI。在 doAdd 方法中,新创建的 PostItem 被 unshift 到列表头部,框架会自动在列表顶部插入新的动态卡片而无需手动操作 DOM。这种数据驱动的 UI 更新模式是 ArkUI 与传统命令式框架最本质的区别,也是 @Observed 装饰器的价值所在。

3.4 蜡烛作品数据模型 CandleItem

@Observed
export class CandleItem {
  id: number = 0
  name: string = ''
  scent: string = ''
  wax: string = ''
  status: string = ''
  tag: string = ''
  constructor(id: number, name: string, scent: string, wax: string, status: string, tag: string) {
    this.id = id; this.name = name; this.scent = scent; this.wax = wax
    this.status = status; this.tag = tag
  }
}

CandleItem 类建模了蜡烛作品这一核心业务实体。与 PostItem 类似,它同样使用 @Observed 装饰器,确保属性变化的可追踪性。字段设计紧贴蜡烛行业特性:name 是作品名称(如"白檀夜雪"),scent 描述香调组合(如"檀木·雪松"),wax 标注蜡材类型(如"大豆蜡"“蜂蜡”),status 表示制作状态(“出品”“制作中”“待脱模”“评审”),tag 是分类标签(“助眠”“前调”"景观"等)。这种字段设计将蜡烛作品的多维属性结构化,使得在 UI 渲染时可以根据不同字段值动态选择颜色、图标和布局。

status 字段的设计尤其值得分析。它使用字符串而非枚举类型,这在原型开发阶段提供了灵活性——可以随时增加新的状态值而无需修改类型定义。candleColor 纯函数会根据 status 值返回对应的颜色:出品和评审映射为绿色(success),制作中和待脱模映射为金色(warning),其他映射为红色(danger)。这种"字符串状态+函数映射颜色"的模式在状态种类较少且固定的场景下简洁有效。在 doEdit 方法中,通过 splice 替换数组元素的方式更新蜡烛信息,新 CandleItem 的 status 被设为"制作中",这一变化会通过 @Observed 机制自动反映到列表 UI 上。

3.5 香调配方与蜡材模具数据模型

@Observed
export class RecipeItem {
  id: number = 0
  name: string = ''
  note: string = ''
  ratio: string = ''
  level: string = ''
  constructor(id: number, name: string, note: string, ratio: string, level: string) {
    this.id = id; this.name = name; this.note = note; this.ratio = ratio
    this.level = level
  }
}

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

在这里插入图片描述

RecipeItem 模型化了香调配方实体,其中 note 字段存储了详细的配方组成描述(如"檀木 40% · 雪松 35% · 香根 25%"),ratio 字段存储香精浓度百分比,level 字段标注难度等级(“入门”“进阶”“大师”)。配方模型的设计体现了调香工艺的专业性——每款配方都有精确的成分配比和浓度参数,难度分级帮助用户选择适合自己水平的配方。levelColor 纯函数根据 level 值返回对应颜色:入门为绿色,进阶为金色,大师为红色,形成直观的难度视觉编码。

MoldItem 模型化了蜡材与模具实体,字段较为简洁:name(名称)、desc(描述)、icon(Emoji图标)。这类实体属于商品/工具类信息,不需要复杂的状态管理,但仍然使用 @Observed 装饰,保持了与整体数据模型的一致性。icon 字段使用 Emoji(🧁、🥃、🕯️等)代替图片资源,是原型开发中的轻量级做法——无需准备图片素材即可实现视觉化展示,同时也减小了应用体积。在实际产品化时,这些 Emoji 可以替换为真实的图片 URL 或 Resource 资源引用,数据模型结构无需改动。

3.6 图表与课程接口定义

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

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

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

这里定义了三个纯数据接口,用于图表和课程数据的结构化描述。SaleChartItem 用于周销量柱状图,label 存储星期标签,value 存储销量数值。ScentChartItem 在 SaleChartItem 基础上增加了 color 字段,因为香调占比图需要每条数据有独立的颜色。CourseItem 用于烛艺课堂的课程进度展示,title 是课程名称,week 标注教学周期,progress 是进度百分比,color 是进度条颜色。

与 @Observed 修饰的 class 不同,这三个 interface 用于纯静态数据,不会被状态管理机制追踪。它们的实例化数据(SALE_CHART、SCENT_CHART、COURSE_LIST)以 const 常量形式定义在模块顶层,在整个应用生命周期中不可变。这种区分设计是合理的——图表和课程数据在本应用中作为展示型数据存在,不需要增删改操作,因此不需要 @Observed 的响应式能力。如果未来课程需要支持进度更新(如用户学习后 progress 增长),则应将 CourseItem 改为 @Observed class 并通过 @State 数组引用,以实现进度的动态更新。接口与 class 的合理选用,体现了对数据可变性需求的精确把控。

3.7 顶层静态数据 - 烛友动态与蜡烛作品列表

const POST_LIST: PostItem[] = [
  new PostItem(1, '烛光', '🕯️', '新调的大豆蜡「白檀夜雪」,烛芯偏短一点燃烧更稳定。', '9分钟前', 82),
  new PostItem(2, '蜡匠', '🧊', '冰花蜡出炉,冷却速度决定花纹,冰箱法成功率最高。', '25分钟前', 67),
  new PostItem(3, '香调师', '🌸', '樱花+白桃前调试香完成,甜而不腻,下周上架。', '1小时前', 74),
  new PostItem(4, '烛友', '✨', '第一次做果冻蜡海洋杯,贝壳摆太久有点移位。', '2小时前', 45),
  new PostItem(5, '温控王', '🌡️', '蜂蜡熔点 62-65°C,加温过头会发黄,分享测温笔记。', '3小时前', 58)
];

在这里插入图片描述

POST_LIST 是烛友动态的初始数据源,以 const 常量数组形式定义,包含八条模拟动态。每条动态都经过精心设计,内容涵盖蜡烛制作的各个方面:蜡材选择、温控技巧、香调配比、模具脱模、烛芯测试、助眠应用等,既体现了社区内容的专业性,又展示了应用覆盖的业务广度。用户昵称采用与蜡烛相关的趣味化命名(烛光、蜡匠、香调师、温控王等),头像使用相关 Emoji,增强了社区的角色代入感。time 字段使用相对时间格式(“9分钟前”“昨天”),符合社交应用的时间展示习惯。

CANDLE_LIST 同样以 const 常量定义,包含八款蜡烛作品数据。每款蜡烛都有独特的名称和香调组合,蜡材类型覆盖了大豆蜡、混合蜡、果冻蜡、蜂蜡、石蜡、棕榈蜡、椰子蜡等多种类型,status 涵盖了出品、制作中、待脱模、评审四种状态,tag 包括助眠、前调、景观、天然、复古、结晶、限定等多种分类。这种数据设计确保了 UI 展示时的丰富性和多样性——每张蜡烛卡片都有不同的颜色编码和标签组合,避免了视觉上的重复感。同时,八条数据的规模适中,既能在列表中产生足够的视觉信息密度,又不会因为数据量过大而影响渲染性能。

3.8 香调配方、蜡材模具与图表课程静态数据

const RECIPE_LIST: RecipeItem[] = [
  new RecipeItem(1, '白檀夜雪', '檀木 40% · 雪松 35% · 香根 25%', '8%', '进阶'),
  new RecipeItem(2, '樱花白桃', '樱花 50% · 白桃 50%', '6%', '入门'),
  new RecipeItem(3, '苔原雪松', '雪松 60% · 苔藓 25% · 琥珀 15%', '9%', '进阶'),
  new RecipeItem(4, '月光浴', '茉莉 45% · 白茶 40% · 晚香玉 15%', '7%', '入门'),
  new RecipeItem(5, '琥珀光', '琥珀 55% · 麝香 30% · 香草 15%', '10%', '大师'),
  new RecipeItem(6, '蜂蜜花香', '蜂蜡本香 · 玫瑰 30%', '5%', '入门')
];

const SALE_CHART: SaleChartItem[] = [
  { label: '周一', value: 16 },
  { label: '周二', value: 22 },
  { label: '周三', value: 19 },
  { label: '周四', value: 30 },
  { label: '周五', value: 42 },
  { label: '周六', value: 55 },
  { label: '周日', value: 48 }
];

RECIPE_LIST 包含六条配方数据,每条配方的 note 字段都详细标注了各香调成分的精确百分比,ratio 字段标注了总体香精浓度。难度等级从入门到大师分布合理,其中入门配方有3条、进阶配方有2条、大师配方有1条,符合"从易到难递减"的教学曲线设计。配方名称与蜡烛作品列表中的名称保持一致(如"白檀夜雪"“月光浴”“琥珀光”),建立了配方与产品之间的数据关联,用户可以从配方页查看组成,也可以从蜡烛页查看成品,两个维度相互补充。

SALE_CHART 定义了一周的销量数据,从周一到周日共七个数据点。数据走势模拟了真实零售场景的规律:工作日较低(16-30),周末高峰(42-55),周日略有回落(48),最高值出现在周六。这种数据分布使得柱状图在视觉上呈现出明显的"周末高峰"形态,配合 barH 函数的高度计算和 styleColor 函数的颜色映射,可以生成直观且有信息量的图表。SCENT_CHART 定义了五大香调家族的占比数据,每条数据都携带独立的颜色值,用于绘制彩色的横向进度条。COURSE_LIST 定义了五门课程,progress 值从 96% 递减到 13%,模拟了用户从入门到进阶的学习路径,每门课程配有独立的进度条颜色,在视觉上形成五彩斑斓的学习地图。

3.9 导航配置 - 主Tab与子Tab

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

const NAV_LIST: NavItem[] = [
  { icon: '🕯️', label: '首页' },
  { icon: '🧊', label: '烛坊' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '蜡烛作品', '香调配方', '蜡材模具', '烛艺课堂', '烛友圈', '工坊预约'];

NAV_LIST 定义了底部导航栏的四个主Tab配置,每个 Tab 由 Emoji 图标和文字标签组成。首页使用 🕯️(蜡烛),烛坊使用 🧊(冰花蜡),课堂使用 📚(书籍),我的使用 👤(人像),图标选择与功能含义高度匹配。NavItem 接口简洁明了,icon 和 label 两个字段足以描述底部 Tab 的所有视觉信息。使用 Emoji 作为 Tab 图标是原型开发中的高效选择,无需准备图标资源文件即可实现视觉化导航,在正式产品中可替换为 Icon 组件或 SVG 资源。

SUB_NAV_LIST 定义了首页内的七个内容子Tab标签,以字符串数组形式存储。这七个标签覆盖了应用的核心功能模块:精选(数据看板)、蜡烛作品(产品管理)、香调配方(配方库)、蜡材模具(物料库)、烛艺课堂(课程教学)、烛友圈(社区互动)、工坊预约(线下服务)。子Tab的数量设计为七个,在可滚动横向导航条中展示,用户可以通过左右滑动浏览所有选项。使用字符串数组而非对象数组,是因为子Tab的切换逻辑仅依赖索引值(subTab 状态变量),不需要额外的图标或路由配置,数据结构追求最小化。

3.10 纯函数 - 图表辅助函数 barH 与 styleColor

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

function styleColor(v: number): string {
  if (v > 70) {
    return '#E8A84F';
  } else if (v > 50) {
    return '#D98AA4';
  }
  return '#8FBF7E';
}

barH 函数用于计算柱状图中每根柱子的高度像素值,接受当前值 v 和最大值 max 两个参数,返回按比例缩放后的高度。120 是最大柱高基准值(像素),通过 Math.round 取整确保高度为整数,避免渲染时的亚像素模糊。在调用处传入的最大值为 55(SALE_CHART 中的最大销量值),意味着销量 55 对应柱高 120px,其他值按比例缩放。这种将数据范围映射到像素范围的函数是数据可视化中的基础工具,通过参数化的设计,barH 可以适应不同数据集的柱状图渲染需求。

styleColor 函数根据数值返回对应的颜色字符串,实现了"数据驱动的颜色编码"。当值大于 70 时返回主色金(#E8A84F),大于 50 时返回强调色粉(#D98AA4),其余返回成功色绿(#8FBF7E)。这种分段着色策略在销量柱状图中产生了视觉信息层次——高销量的柱子显眼金色,中等销量柔和粉色,低销量温和绿色,用户可以快速通过颜色识别销量高低分布。两个函数都定义在模块顶层,是无副作用的纯函数,相同的输入永远产生相同的输出,不依赖任何外部状态,可以被安全地在任何上下文中调用。

3.11 纯函数 - 烛焰特效函数组

function flameX(tick: number, i: number): number {
  return 30 + ((tick * 4 + i * 89) % 600);
}

function flameY(tick: number, i: number): number {
  return 80 + ((tick * 2 + i * 47) % 340);
}

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

function flameA(tick: number): number {
  return tick % 2 === 0 ? 0.9 : 0.5;
}

这组函数是烛焰跳动特效的核心计算引擎。flameX 计算烛焰粒子的水平位置,通过 tick(时间帧)和 i(粒子索引)的线性组合对 600 取模,产生粒子在 30-630 像素范围内的水平移动。tick 乘以 4 使得每帧水平位移为 4 像素,i 乘以 89 确保不同粒子的初始位置差异足够大且不会出现规律性重叠。flameY 计算垂直位置,范围在 80-420 像素,tick 乘以 2 使得垂直移动速度慢于水平移动,产生更自然的飘动效果。flameS 计算粒子大小(高度),范围在 5-9 像素之间变化,模拟烛焰大小的波动。flameA 计算透明度,在偶数帧为 0.9、奇数帧为 0.5 之间切换,产生闪烁效果。

这组函数的设计体现了"伪随机确定性动画"的思路。通过取模运算而非随机数生成,确保了特效的可重复性——相同的 tick 和 i 永远产生相同的位置和大小,避免了每帧完全随机的"噪点感"。89 和 47 这类质数系数的选用,使得不同粒子的运动轨迹相互错开,在视觉上产生自然的群体飘动效果而非整齐划一的机械运动。四个函数共同作用,在每 90 毫秒的定时器触发时,八个烛焰粒子会各自计算新的位置、大小和透明度,通过 Column 组件的 translate 和 opacity 属性变化呈现为跳动闪烁的烛焰。

3.12 纯函数 - 蜡滴特效与颜色映射函数

function dripY(tick: number, i: number): number {
  return 420 - ((tick * 3 + i * 61) % 360);
}

function dripA(tick: number, i: number): number {
  return (i + tick) % 4 === 0 ? 0.8 : 0.3;
}

function candleColor(s: string): string {
  if (s === '出品' || s === '评审') {
    return '#8FBF7E';
  } else if (s === '制作中' || s === '待脱模') {
    return '#E8A84F';
  }
  return '#D96A5C';
}

function levelColor(s: string): string {
  if (s === '入门') {
    return '#8FBF7E';
  } else if (s === '进阶') {
    return '#E8A84F';
  }
  return '#D96A5C';
}

在这里插入图片描述

dripY 和 dripA 函数服务于蜡滴下落特效。dripY 计算蜡滴的垂直位置,注意这里用 420 减去取模结果,意味着蜡滴从 420 像素处向上计算位置——实际上蜡滴从屏幕上方(60 像素附近)开始,随着 tick 增加逐渐下落到 420 像素附近,然后循环重置。tick 乘以 3 使得下落速度为每帧 3 像素,i 乘以 61 让不同蜡滴的下落节奏错开。dripA 通过 (i + tick) % 4 === 0 的条件,使得每隔几帧就有蜡滴变得明亮(0.8),其余时间较暗(0.3),产生忽明忽暗的滴落效果。

candleColor 和 levelColor 是两个状态-颜色映射函数,分别用于蜡烛状态和配方难度的视觉编码。candleColor 将"出品"和"评审"映射为绿色(表示完成或接近完成),"制作中"和"待脱模"映射为金色(表示进行中),其他映射为红色(表示异常或待处理)。levelColor 将"入门"映射为绿色(容易),"进阶"映射为金色(中等),"大师"映射为红色(困难)。这两个函数在多个页面中被调用,通过字符串匹配返回颜色值,实现了状态信息到视觉色彩的统一转换。集中管理这些颜色映射逻辑,避免了在各处散落硬编码颜色值,提升了代码的可维护性。

3.13 主组件声明与状态变量定义

@Entry
@Component
struct PageCandle {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'candle'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State candleList: CandleItem[] = CANDLE_LIST
  @State fxTimer: number = -1

@Entry 装饰器标识 PageCandle 为应用的入口组件,@Component 声明它是一个可复用的 UI 组件单元。struct 内部定义了十六个 @State 状态变量,构成了应用全部的响应式状态空间。这些状态变量可以分为四组:导航状态(mainTab 主Tab索引、subTab 子Tab索引)、特效状态(tick 帧计数、glow 辉光切换)、弹窗状态(addOpen/editOpen/delOpen 三个布尔开关及其关联的 editIdx/delTarget/editName/editStyle/editNote/addTitle/addContent)以及数据列表状态(postList、candleList)和定时器句柄(fxTimer)。

@State 装饰器是 ArkUI 中最基础的状态管理装饰器,被修饰的变量在值发生变化时会自动触发所属组件的 build 方法重新执行,进而更新依赖该变量的 UI 片段。值得注意的是,postList 和 candleList 初始值引用了模块顶层的 POST_LIST 和 CANDLE_LIST 常量——但因为是 @State 赋值,ArkUI 框架会为这些数组创建响应式代理,后续的 unshift/splice 操作才会触发 UI 更新。fxTimer 初始化为 -1 作为"无定时器"的哨兵值,在 aboutToDisappear 中通过判断 fxTimer > 0 来决定是否清理定时器,避免了重复 clearInterval 的风险。十六个状态变量的规模适中,覆盖了应用所有交互场景的需求,同时通过命名约定(add/edit/del 前缀)保持了良好的可读性。

3.14 生命周期 - aboutToAppear 与 aboutToDisappear

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

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

在这里插入图片描述

aboutToAppear 是 ArkUI 组件的生命周期回调,在组件实例创建后、build 方法首次执行前调用。这里在 aboutToAppear 中启动了一个 90 毫秒间隔的定时器,将返回的定时器 ID 存储到 fxTimer 状态变量中。定时器回调内执行两个操作:tick 自增驱动特效层中所有粒子的位置/大小/透明度计算,glow 在 0 和 1 之间切换(通过取模实现双态切换)。90 毫秒的间隔约为 11 FPS,这是一个刻意偏低的帧率选择——既能让烛焰跳动效果可见,又不至于过度消耗 CPU 和电池。对于蜡烛工坊这种以静态内容为主、特效为氛围烘托的应用,不需要 60 FPS 的流畅动画,11 FPS 的跳动反而更接近真实烛焰的闪烁节奏。

aboutToDisappear 在组件销毁时调用,负责清理 aboutToAppear 中创建的资源。这里检查 fxTimer 是否大于 0(即定时器确实存在),然后调用 clearInterval 清除定时器并将 fxTimer 重置为 -1。这一步至关重要——如果不清理定时器,即使组件已经销毁,定时器回调仍会继续执行,尝试修改已销毁组件的状态变量会导致内存泄漏或运行时异常。fxTimer 重置为 -1 而非 0,与初始化值保持一致,确保如果组件因某种原因被重新创建,aboutToDisappear 的判断逻辑仍然有效。这种"创建-使用-清理"的完整生命周期管理,是避免资源泄漏的最佳实践。

3.15 弹窗控制方法组

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

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

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

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

这组方法负责弹窗的打开与状态预填充。openAdd 在打开新增弹窗前清空 addTitle 和 addContent,确保每次打开都是干净的输入状态。openEdit 接受 index 参数,先做边界检查(index >= 0 且 < 数组长度),然后将对应蜡烛的 name、scent、wax 分别预填充到 editName、editStyle、editNote 中,实现"编辑"的语义——用户看到的输入框已填充当前值,修改后提交即可更新。openDelA 和 openDelB 分别设置 delTarget 为 ‘candle’ 和 ‘course’,然后打开删除弹窗。delTarget 的值决定了 delModalBody 中的文案和 doDel 中的操作目标。

这组方法体现了"集中管理弹窗状态"的设计模式。所有弹窗的打开逻辑都在组件方法中统一处理,而非散落在各 Builder 的 onClick 中内联编写,提升了代码的可维护性和可测试性。边界检查(openEdit 中的 index 范围判断)确保了即使传入非法索引也不会导致数组越界异常。delTarget 字符串值的设计(‘candle’/‘course’)虽然不如枚举类型安全,但在小规模应用中足够清晰,且在 delModalBody 和 doDel 中通过简单的 === 比较即可路由到正确的逻辑分支。如果未来需要增加更多弹窗类型,可以按同样的模式扩展 openXxx 方法和 xxxOpen 状态变量。

3.16 数据操作方法 doAdd/doEdit/doDel

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

doEdit(): void {
  if (this.editIdx >= 0 && this.editIdx < this.candleList.length) {
    this.candleList.splice(this.editIdx, 1, new CandleItem(this.candleList[this.editIdx].id, this.editName, this.editStyle, this.editNote, '制作中', '新品'));
  }
  this.editOpen = false;
}

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

在这里插入图片描述

doAdd 方法在用户确认发布烛语时调用,先检查 addTitle 非空(至少有一个字符),然后通过 unshift 在 postList 头部插入一条新的 PostItem 实例。新动态的 id 固定为 999(原型开发的简化处理),昵称为"我的烛语",头像使用 🕯️,时间为"刚刚",点赞数为 0。unshift 操作会触发 @State 数组的响应式更新,ForEach 列表会自动在顶部插入新卡片。doEdit 通过 splice 的三参数形式(splice(index, 1, newItem))替换指定位置的元素,新 CandleItem 复用原 id 但更新了 name/scent/wax 字段,status 统一设为"制作中"、tag 设为"新品"。

doDel 根据 delTarget 的值决定操作目标:‘candle’ 时从 candleList 头部删除一条(splice(0, 1)),‘course’ 时从 postList 头部删除一条。删除前都检查了数组长度大于 0,避免空数组操作。三个 do 方法的共同模式是:执行数据操作后关闭对应弹窗(将 xxxOpen 设为 false)。这种"操作-关闭"的原子性设计确保了弹窗状态与数据状态的同步——不存在弹窗已关闭但数据未更新的不一致状态。splice 是 ArkUI 中触发 @State 数组响应式更新的推荐方法之一(另一个是 unshift),框架会监听这些变更并自动更新 ForEach 渲染的列表项。

3.17 特效层 Builder fxLayer

@Builder
fxLayer() {
  Stack({ alignContent: Alignment.TopStart }) {
    ForEach([0, 1, 2, 3, 4, 5, 6, 7], (i: number) => {
      Column()
        .width(4)
        .height(flameS(this.tick, i) * 2)
        .borderRadius(4)
        .backgroundColor(COLORS.flame)
        .opacity(flameA(this.tick))
        .translate({ x: flameX(this.tick, i), y: flameY(this.tick, i) })
    }, (i: number) => i.toString())
    ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
      Column()
        .width(3)
        .height(8)
        .borderRadius(2)
        .backgroundColor('#F0C47E')
        .opacity(dripA(this.tick, i))
        .translate({ x: 50 + i * 88, y: dripY(this.tick, i) })
    }, (i: number) => i.toString())
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

在这里插入图片描述

fxLayer 是烛焰跳动和蜡滴下落特效的渲染 Builder。外层 Stack 以 TopStart 对齐方式容纳两组 ForEach 渲染的粒子。第一组 ForEach 渲染八个烛焰粒子,每个粒子是一个 Column 组件,宽度固定 4 像素、高度由 flameS 函数计算(5-9 的两倍即 10-18 像素)、圆角 4 像素、背景色为 flame 专用色、透明度由 flameA 函数计算(0.9 或 0.5 交替)、位置由 flameX 和 flameY 函数计算并通过 translate 属性应用。第二组 ForEach 渲染七个蜡滴粒子,宽度 3 像素、高度固定 8 像素、圆角 2 像素、背景色为浅金色 #F0C47E、透明度和垂直位置由 dripA 和 dripY 函数计算,水平位置固定为 50 + i * 88(均匀分布)。

hitTestBehavior(HitTestMode.None) 是特效层的关键设计——它使得整个 fxLayer 不参与触摸事件命中测试,所有触摸事件会穿透到下层的实际内容区域。这意味着烛焰粒子和蜡滴虽然占据了屏幕空间且在视觉上可见,但完全不影响用户与下方内容的交互。特效层的 Stack 设置为 100% 宽高,覆盖整个屏幕,作为背景氛围层存在。每 90 毫秒 tick 更新时,所有粒子的 translate 和 opacity 属性会重新计算,产生跳动和下落的动画效果。ForEach 的第三个参数(键值生成器)使用 i.toString(),确保粒子在 tick 变化时不会被销毁重建,而是通过属性更新实现动画——这比每次重建组件高效得多。

3.18 头部 Builder header

@Builder
header() {
  Row() {
    Column() {
      Text('烛语工坊')
        .fontSize(21)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text('蜡烛作品 · 香调配方 · 烛艺课堂')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Text('🕯️')
      .fontSize(24)
    Text('🧊')
      .fontSize(22)
      .margin({ left: 10 })
  }
  .width('100%')
  .padding({ top: 8, bottom: 8 })
}

在这里插入图片描述

header Builder 构建了应用顶部的品牌识别区域。整体采用 Row 水平布局,左侧是包含主标题和副标题的 Column,右侧是两个装饰性 Emoji 图标(🕯️ 和 🧊)。主标题"烛语工坊"使用 21 号粗体字、暖白色(textPrimary),副标题"蜡烛作品 · 香调配方 · 烛艺课堂"使用 11 号字、次级文本色(textSecondary),两行文字之间有 2 像素的顶部间距。左侧 Column 通过 layoutWeight(1) 占据剩余空间,alignItems(HorizontalAlign.Start) 使文字左对齐而非默认居中。右侧两个 Emoji 分别为 24 和 22 号字,第二个 Emoji 有 10 像素左间距。

头部设计简洁但信息密度恰当:主标题传递品牌名,副标题用三个关键词概括应用的核心功能领域,右侧 Emoji 起到视觉点缀作用。padding 上下各 8 像素,使头部区域有适度的呼吸空间。这个 Builder 在 mainContent 中被调用,作为所有主Tab页面的公共头部,保证品牌识别的一致性。使用 @Builder 而非独立 @Component 的考量是:头部不需要独立的状态管理和生命周期,它纯粹是 UI 片段的复用,@Builder 的轻量级特性恰好满足需求,避免了组件实例化的额外开销。

3.19 内容Tab横条 Builder subNav

@Builder
subNav() {
  Scroll() {
    Row({ space: 8 }) {
      ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
        Text(item)
          .fontSize(13)
          .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
          .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .borderRadius(16)
          .onClick(() => {
            this.subTab = idx;
          })
      }, (item: string) => item)
    }
    .width('100%')
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .padding({ top: 4, bottom: 10 })
}

subNav Builder 构建了首页内的横向可滚动子Tab导航条。外层 Scroll 组件设置 scrollable(ScrollDirection.Horizontal) 实现水平滚动,scrollBar(BarState.Off) 隐藏滚动条保持视觉整洁。内部 Row 以 space: 8 的间距排列七个 Text 标签项。每个标签根据 this.subTab === idx 的判断结果应用两套样式:选中时为粗体、白色文字、主色背景;未选中时为常规字重、次级文本色、卡片背景色。padding 设置为左右 14、上下 7,borderRadius 16 产生药丸形圆角标签。点击事件通过 onClick 将 subTab 更新为对应索引值。

subTab 状态变量的变化会触发 ArkUI 的条件渲染更新——mainContent 中的 if-else 链会根据新的 subTab 值选择对应的页面 Builder 进行渲染。这种"Tab切换=条件渲染"的模式在 ArkUI 中非常常见,它比传统的 ViewPager + Fragment 模式更简洁,但代价是切换时会重新构建整个页面组件树。对于本应用中内容量适中的各页面而言,重建开销可以接受。ForEach 的键值生成器使用 item 字符串本身,因为子Tab标签是唯一的,这确保了标签在 subTab 变化时不会重建而是仅更新样式属性。padding 上下 4 和 10 的不对称设计,为导航条提供了与上方头部和下方内容区的视觉分隔。

3.20 精选页面 - 销量柱状图

@Builder
pageFeatured() {
  Column({ space: 12 }) {
    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(SALE_CHART, (it: SaleChartItem) => {
          Column({ space: 4 }) {
            Column()
              .width(22)
              .height(barH(it.value, 55))
              .borderRadius(6)
              .backgroundColor(styleColor(it.value))
            Text(it.label)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }, (it: SaleChartItem) => it.label + it.value.toString())
      }
      .width('100%')
      .alignItems(VerticalAlign.Bottom)
      .height(150)
      .margin({ top: 12 })

在这里插入图片描述

pageFeatured Builder 构建了精选页面,这是首页第一个子Tab的内容。首先是一个销量柱状图大卡片,卡片头部 Row 中左侧为"本周蜡烛销量"标题(16号粗体字),右侧为"单位 百支"的单位标注(10号字、提示色)。柱状图区域使用 Row({ space: 10 }) 水平排列七根柱子,每根柱子是一个 Column({ space: 4 }),包含一个可变高度的色块 Column 和底部标签 Text。色块宽度固定 22 像素,高度由 barH(it.value, 55) 计算(最大 120 像素),圆角 6 像素,背景色由 styleColor(it.value) 根据数值返回。

柱状图区域的关键属性是 alignItems(VerticalAlign.Bottom) 和 height(150)。VerticalAlign.Bottom 使得所有柱子从底部对齐——高度较小的柱子会在顶部留白,而非从顶部对齐导致底部参差不齐。height(150) 为图表区域提供了固定高度,配合 margin({ top: 12 }) 与上方标题行分隔。ForEach 的键值生成器使用 label + value 的拼接字符串,确保每条数据的唯一性。柱状图下方还有周均销量数据行,使用 Row({ space: 6 }) 展示"周均销量"标签和"33.1 百支"数值。整个卡片使用 cardBg 背景、14 像素 padding 和 14 像素圆角,与应用统一的卡片样式保持一致。

3.21 精选页面 - 香调占比与热门蜡烛

      // 香调占比
      Column() {
        Text('香调占比')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
        ForEach(SCENT_CHART, (it: ScentChartItem) => {
          Row({ space: 8 }) {
            Text(it.label)
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .width(56)
            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: ScentChartItem) => it.label)
      }

香调占比卡片采用了横向进度条的图表形式,每条数据展示为"标签-进度条-数值"的三段式布局。标签列固定 56 像素宽度,进度条区域通过 layoutWeight(1) 自适应占据剩余空间,数值列固定 44 像素宽度且右对齐。进度条使用 Stack({ alignContent: Alignment.Start }) 叠加两层 Row:底层为 100% 宽度的灰色轨道(border 色),上层为 ${it.value}% 宽度的彩色填充条,两层的 height 和 borderRadius 保持一致(8 像素、4 像素圆角),形成视觉上嵌套的进度条效果。

热门蜡烛区域采用了双列卡片布局,通过 if (idx % 2 === 0) 条件控制每隔一项渲染一行 Row,每行包含左右两个 Column 卡片。这种"手动双列"的实现方式虽然代码量较多,但在 ArkUI 中是常见的列表分列模式。每张卡片展示蜡烛名称(15号粗体字)、香调与蜡材的组合描述(11号次级文本色)、以及状态标签和分类标签的 Row。状态标签使用 candleColor 函数返回的颜色,背景为 bg 色、6 像素圆角。卡片整体使用 cardBg 背景、12 像素 padding、12 像素圆角。双列布局使得蜡烛作品以网格形式呈现,比单列列表更紧凑且视觉信息密度更高。

3.22 精选页面 - 烛友动态

      // 烛友动态
      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 })

精选页面底部的烛友动态区域展示了 postList 的前三条动态(通过 slice(0, 3) 截取)。区域头部 Row 中,"发烛语"文字按钮使用主色文字,点击触发 openAdd 方法打开新增弹窗。每条动态以 Row({ space: 10 }) 布局,左侧是 22 号字大小的 Emoji 头像,右侧是包含昵称行、正文和点赞行的 Column。昵称行中,昵称使用粗体并 layoutWeight(1) 占据剩余空间,时间标注使用提示色右对齐。正文 Text 设置了 maxLines(2) 和 textOverflow({ overflow: TextOverflow.Ellipsis }),当正文超过两行时以省略号截断,保持卡片高度的一致性。

slice(0, 3) 的使用是精选页面的一个设计选择——只展示最新的三条动态作为预览,完整列表在"烛友圈"子Tab中展示。这种"预览+完整"的内容分层策略在信息密集型应用中很常见,既保证了首页的信息丰富度,又避免了过长的列表影响浏览体验。maxLines 和 textOverflow 的组合是 ArkUI 中处理长文本截断的标准做法,TextOverflow.Ellipsis 在文本末尾添加省略号,比直接裁剪更友好。点赞行使用 👍 Emoji 配合数字展示,简洁直观。整个动态卡片使用 cardBg 背景、12 像素 padding、12 像素圆角,与全应用卡片样式一致。

3.23 蜡烛作品页 pageCandle

@Builder
pageCandle() {
  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.candleList, (it: CandleItem, idx: number) => {
      Column({ space: 6 }) {
        Row() {
          Column()
            .width(4)
            .height(38)
            .borderRadius(2)
            .backgroundColor(it.status === '出品' ? COLORS.success : COLORS.warning)
          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(candleColor(it.status))
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(COLORS.bg)
                .borderRadius(6)
            }

pageCandle Builder 构建了蜡烛作品列表页,该页面在两个位置被使用:首页的"蜡烛作品"子Tab和"烛坊"主Tab。页面顶部 Row 中,"新建作品"文字按钮点击触发 openAdd 方法。列表通过 ForEach 遍历 candleList 渲染每款蜡烛的卡片。每张卡片是一个 Column({ space: 6 }),内部第一行 Row 的左侧是一个 4 像素宽、38 像素高的色条 Column,背景色根据 status 判断:‘出品’ 为绿色,其他为金色。这个色条起到了视觉状态指示器的作用,用户扫过列表时能快速识别哪些蜡烛已出品、哪些仍在制作中。

色条右侧的 Column({ space: 4 }) 包含两行内容:第一行是蜡烛名称(14号粗体字,layoutWeight(1) 占据剩余空间)和状态标签(使用 candleColor 函数返回颜色,bg 色背景,6 像素圆角的小药丸标签);第二行是香调和蜡材信息,使用 🌸 和 🫧 Emoji 作为视觉前缀,11 号次级文本色。卡片底部还有一个右对齐的"编辑"文字按钮,点击触发 openEdit(idx) 打开编辑弹窗。页面底部是一个红色背景的"下架蜡烛"按钮,使用了一种特殊实现:Button 组件设置 onClick 触发 openDelA,然后通过一个 Text 组件以负 margin 叠加在 Button 上方显示文字——这种"按钮+叠加文字"的模式在本应用中多次出现,用于实现自定义样式的按钮。

3.24 香调配方页 pageRecipe

@Builder
pageRecipe() {
  Column({ space: 10 }) {
    Text('香调配方')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .width('100%')
    ForEach(RECIPE_LIST, (it: RecipeItem) => {
      Column({ space: 6 }) {
        Row() {
          Text(it.name)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(it.level)
            .fontSize(10)
            .fontWeight(FontWeight.Bold)
            .fontColor(levelColor(it.level))
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS.bg)
            .borderRadius(6)
        }
        .width('100%')
        Text(it.note)
          .fontSize(12)
          .fontColor(COLORS.textSecondary)
        Row({ space: 8 }) {
          Text('香精浓度')
            .fontSize(11)
            .fontColor(COLORS.textHint)
          Text(it.ratio)
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .width('100%')
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (it: RecipeItem) => it.id.toString())
  }
  .width('100%')
}

pageRecipe Builder 构建了香调配方列表页。页面顶部是一个居左对齐的"香调配方"标题。ForEach 遍历 RECIPE_LIST 渲染六条配方数据,每条配方的卡片结构为三层 Column:第一层 Row 包含配方名称(14号粗体字,layoutWeight(1))和难度等级标签(使用 levelColor 函数返回颜色,bg 色背景的药丸标签);第二层是配方组成详情 Text(12号次级文本色,展示如"檀木 40% · 雪松 35% · 香根 25%"的成分配比);第三层是香精浓度 Row,左侧"香精浓度"标签使用提示色,右侧浓度值使用主色粗体。

配方卡片的设计层次清晰:名称和等级在同一行作为卡片标题,配方组成作为主体内容,浓度作为底部参数行。levelColor 函数的应用使得入门配方的等级标签为绿色、进阶为金色、大师为红色,用户可以快速识别配方难度。ForEach 的键值生成器使用 it.id.toString(),确保配方数据的唯一标识。卡片样式使用统一的 cardBg 背景、12 像素 padding 和 12 像素圆角。pageRecipe 是纯展示型页面,没有编辑或删除操作,RECIPE_LIST 作为 const 常量在应用运行期间不可变,这简化了状态管理但限制了交互能力——在实际产品中,可能需要支持配方的创建和编辑功能。

3.25 蜡材模具页 pageMold

@Builder
pageMold() {
  Column({ space: 10 }) {
    Text('蜡材模具')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .width('100%')
    ForEach(MOLD_LIST, (it: MoldItem, idx: number) => {
      if (idx % 2 === 0) {
        Row({ space: 10 }) {
          if (idx < MOLD_LIST.length) {
            Column({ space: 6 }) {
              Text(MOLD_LIST[idx].icon)
                .fontSize(26)
              Text(MOLD_LIST[idx].name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(MOLD_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)
          }

pageMold Builder 构建了蜡材模具的双列网格页面。与精选页面的热门蜡烛双列类似,这里也使用 if (idx % 2 === 0) 条件控制每两项渲染一行 Row。每行包含左右两个 Column 卡片,通过 idx 和 idx+1 分别访问 MOLD_LIST 中的相邻元素。每张卡片内部是一个 Column({ space: 6 }),依次排列:26 号字的 Emoji 图标、14号粗体字的名称、11号次级文本色的描述、以及11号主色字的"去购买"链接文字。卡片使用 alignItems(HorizontalAlign.Start) 使内容左对齐,layoutWeight(1) 使两列等宽分布。

蜡材模具页的数据来自 MOLD_LIST 常量,包含八种蜡材和工具(硅胶模具、玻璃蜡杯、棉质烛芯、蜂蜡片、大豆蜡、果冻蜡、香精套装、融蜡锅),每种都有 Emoji 图标和简短描述。"去购买"文字链接使用主色文字模拟电商链接的视觉效果,但在本应用中没有实际的跳转逻辑。双列网格布局使得八种蜡材以紧凑的四行两列形式展示,比单列列表节省了垂直空间。pageMold 的双列实现方式与 pageFeatured 中的热门蜡烛区域完全一致,这种模式的一致性有利于代码维护——开发者熟悉一种双列实现后即可理解所有类似布局。

3.26 烛艺课堂页 pageCourse

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

pageCourse Builder 构建了烛艺课堂的课程进度页面。ForEach 遍历 COURSE_LIST 渲染五门课程,每张课程卡片的结构为:标题行(课程名称粗体 + 周期标注提示色)、进度条(Stack 叠加的横向进度条,与香调占比图相同的实现方式)、以及底部行("已学"标签 + 百分比数值)。进度条的填充宽度使用模板字符串 ${it.progress}% 动态计算,颜色使用每门课程独立的 color 值,五门课程各不相同,在视觉上形成彩虹般的学习地图效果。

课程卡片的设计体现了教学场景的信息层次:用户首先看到课程名称和教学周期,然后通过进度条直观了解学习进度,底部数字行提供了精确的百分比数值。五门课程的 progress 值从 96% 递减到 13%,模拟了用户从入门课程(已基本完成)到进阶课程(刚开始学习)的学习路径。页面底部还有一个红色"退课"按钮,点击触发 openDelB 方法,将 delTarget 设为 ‘course’ 并打开删除弹窗。与蜡烛作品页的"下架蜡烛"按钮类似,这里也使用了 Button + 叠加 Text 的自定义按钮模式。pageCourse 在两个位置被使用:首页的"烛艺课堂"子Tab和"课堂"主Tab,体现了 Builder 的复用价值。

3.27 工坊预约页 pageStudio

@Builder
pageStudio() {
  Column({ space: 10 }) {
    Text('工坊预约')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .width('100%')
    Column({ space: 6 }) {
      Row() {
        Text('果冻蜡海洋杯')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('🕘 明日 14:00')
          .fontSize(11)
          .fontColor(COLORS.warning)
      }
      .width('100%')
      Row({ space: 12 }) {
        Text('工位:C-2')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        Text('时长:2 小时')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
      }
      .width('100%')
      Text('含材料包:果冻蜡 200g + 贝壳装饰')
        .fontSize(12)
        .fontColor(COLORS.primary)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)

pageStudio Builder 构建了工坊预约页面,这是首页最后一个子Tab的内容。页面内容分为三个部分:预约信息卡片(两张)、工位预约网格(四个)。第一张预约卡片展示了"果冻蜡海洋杯"体验活动,包含活动名称、时间(使用 warning 色高亮的"明日 14:00")、工位编号、时长和材料包详情。第二张卡片展示"冰花蜡体验",结构类似但时间标注使用提示色(因为周六更远)。两张卡片的颜色编码有细微差异:近期预约使用 warning 色突出紧迫感,远期预约使用 hint 色弱化。

工位预约网格区域使用嵌套的 Row({ space: 10 }) 布局,两行各包含两个工位卡片。每个工位卡片是一个 Column,包含 Emoji 图标、工位名称、描述和"去预约"链接。四个工位分别是融蜡台、调香台、脱模台和展售架,涵盖了蜡烛制作的核心工序环节。工位卡片与蜡材模具卡片的结构高度相似——都使用 Emoji + 名称 + 描述 + 链接的布局模式——但这里使用的是手动平铺而非 ForEach 渲染,因为四个工位的数据是硬编码在 Builder 中的而非来自数据数组。pageStudio 是纯展示型页面,没有状态变量绑定,所有内容都是静态的,这使得该页面在任何状态下渲染结果一致。

3.28 烛友圈与我的页

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

pageCircle Builder 构建了烛友圈页面,展示完整的 postList 动态列表(不截取)。与精选页面中的动态预览不同,这里的每条动态使用更大的 26 号 Emoji 头像、14 号字昵称和 13 号字正文,信息展示更完整。每条动态底部还有点赞和回复的互动行(👍 ${it.likes} · 💬 回复),虽然在本应用中没有实际的点赞和回复功能,但视觉上提供了社交互动的入口暗示。"发烛语"按钮同样触发 openAdd 方法,与精选页面的动态预览区共享同一个新增弹窗。

pageMine Builder 构建了"我的"主Tab页面,包含三个卡片:用户信息卡片(🦉 头像 + "烛艺师"昵称 + 等级和统计信息)、我的蜡烛卡片(展示 candleList.slice(0, 3) 的前三项,使用 candleColor 着色状态)、我的动态卡片(展示 postList.slice(0, 3) 的前三项)。用户信息卡片使用 40 号字的 Emoji 头像和 18 号粗体字的昵称,统计信息"Lv.15 · 制烛 96 支 · 调香 22 款"用 11 号字次级文本色展示。蜡烛和动态卡片都使用了"标题+数量+列表预览"的结构,标题行中数量值使用主色突出显示。pageMine 作为"我的"主Tab的唯一内容,提供了个人数据和近期活动的概览。

3.29 弹窗遮罩与新增弹窗

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

@Builder
addModalBody() {
  Column({ space: 12 }) {
    Text('发布烛语')
      .fontSize(17)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
    TextInput({ placeholder: '记录今天的制烛见闻…', text: this.addTitle })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(40)
      .onChange((v: string) => {
        this.addTitle = v;
      })
    TextArea({ placeholder: '补充细节:蜡材、香调、模具、配方…', text: this.addContent })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(90)
      .onChange((v: string) => {
        this.addContent = v;
      })

modalOverlay Builder 构建了所有弹窗共用的模态遮罩层。一个全屏 Stack 内包含一个 100% 宽高的 Column,背景色为纯黑 #000000、透明度 0.6(60% 不透明),点击时同时将 addOpen、editOpen、delOpen 三个状态变量设为 false,关闭任何打开的弹窗。这种"遮罩点击关闭"是模态弹窗的标准交互模式,用户可以通过点击遮罩区域快速取消操作。遮罩层使用固定透明度而非动画过渡,在原型开发中足够使用;在实际产品中,可以通过 animateTo 为透明度变化添加渐变动画提升体验。

addModalBody Builder 构建了新增弹窗的内容体,包含标题"发布烛语"、TextInput 输入框(标题输入)、TextArea 多行文本域(内容输入)和底部按钮行。TextInput 和 TextArea 都通过 text 参数绑定到对应的状态变量(this.addTitle 和 this.addContent),onChange 回调将输入值同步回状态变量。这种"双向绑定"的模式确保了输入框内容与状态变量的一致性——状态变量变化时输入框更新,用户输入时状态变量更新。底部按钮行使用与蜡烛列表页相同的 Button + 叠加 Text 模式:左侧"取消"按钮使用 bg 色背景点击关闭弹窗,右侧"发布"按钮使用 primary 主色背景点击触发 doAdd 方法。

3.30 编辑弹窗与删除弹窗

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

editModalBody Builder 构建了编辑弹窗的内容体,结构与 addModalBody 类似但输入字段更多:蜡烛名称 TextInput、香调 TextInput、蜡材与工艺说明 TextArea。三个输入框分别绑定到 editName、editStyle、editNote 状态变量,这些变量在 openEdit 方法中已被预填充为当前蜡烛的 name、scent、wax 值,因此用户打开编辑弹窗时看到的输入框已包含当前值,可以在此基础上修改。这种"预填充+编辑+提交"的流程是表单编辑的标准模式,相比空白输入更符合用户的编辑预期。

delModalBody Builder 构建了删除确认弹窗,根据 delTarget 的值显示不同的标题和描述文案:‘candle’ 时为"下架蜡烛"和"将从展售架下架首支蜡烛,确认执行?“,‘course’ 时为"退出烛艺课堂"和"将退出首门进行中的课程,确认执行?”。弹窗没有输入框,只有描述文字和底部按钮行。底部按钮同样使用 Button + 叠加 Text 模式:左侧"取消"按钮 bg 色背景,右侧"确认"按钮使用 danger 红色背景,点击触发 doDel 方法。三个弹窗体(addModalBody、editModalBody、delModalBody)共用同一个 modalOverlay 遮罩层,在 build 方法中通过条件渲染(if (this.addOpen) 等)选择显示哪个弹窗。

3.31 底部导航与主内容区

@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('#3A2A1F')
  .borderRadius({ topLeft: 16, topRight: 16 })
}

bottomBar Builder 构建了底部四Tab导航栏。ForEach 遍历 NAV_LIST 渲染四个 Tab 项,每个 Tab 是一个 Column({ space: 2 }),包含 Emoji 图标(20号字)和文字标签(10号字)。选中态和未选中态通过三重样式区分:图标 opacity(1 vs 0.55)、标签字重(Bold vs Normal)和标签颜色(primary 主色 vs textHint 提示色)。每个 Tab 通过 layoutWeight(1) 等分宽度,alignItems(HorizontalAlign.Center) 使图标和文字水平居中。点击事件将 mainTab 更新为对应索引值,触发 mainContent 中的条件渲染切换。

底部导航栏整体使用 56 像素固定高度,背景色 #3A2A1F(介于 bg 和 cardBg 之间的深褐色),顶部左右各 16 像素圆角,形成与上方内容区的视觉分隔。mainContent Builder 是应用的内容主体调度器,它先调用 header() 渲染头部,然后根据 mainTab 的值选择内容:mainTab === 0 时渲染 subNav 和可滚动的子Tab内容(通过 if-else 链选择七个子页面之一);mainTab === 1 时渲染 pageCandle;mainTab === 2 时渲染 pageCourse;mainTab === 3 时渲染 pageMine。所有内容区域都包裹在 Scroll 组件中,设置 scrollable(ScrollDirection.Vertical) 和 scrollBar(BarState.Off),通过 layoutWeight(1) 填充头部和底部导航之间的空间。

3.32 build 主布局

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

build 方法是组件的渲染入口,使用 Stack 作为根容器实现分层叠加。最底层是一个全屏的纯色背景 Column,然后是 fxLayer 特效层(烛焰+蜡滴),接着是主内容 Column(包含 mainContent 和 bottomBar),最后是三个条件渲染的弹窗 Stack。Stack 的叠加顺序决定了 z-index 层级——后声明的子元素在上层,因此弹窗在最上层,主内容在特效层之上,特效层在背景层之上。

三个弹窗的条件渲染使用 if (this.addOpen) / if (this.editOpen) / if (this.delOpen) 控制,ArkUI 的条件渲染会在条件为 true 时创建对应组件,为 false 时销毁组件。每个弹窗 Stack 内部先渲染 modalOverlay 遮罩层,再渲染对应的弹窗体 Column(88% 宽度、最大 80% 高度、16 像素圆角、zIndex(999))。constraintSize({ maxHeight: ‘80%’ }) 确保弹窗内容不会超出屏幕 80% 的高度,对于内容较多的编辑弹窗尤为重要。整个 build 方法的结构清晰体现了"背景层-特效层-内容层-弹窗层"的四层架构设计,每一层通过 Stack 的自然叠加实现正确的视觉层级关系。

四、Mermaid 流程图

mainTab === 0

mainTab === 1

mainTab === 2

mainTab === 3

0

1

2

3

4

5

6

addOpen

editOpen

delOpen

无弹窗

应用启动

aboutToAppear 生命周期

启动定时器 fxTimer
间隔90ms

build 方法执行

Stack 根容器渲染

第一层: 背景色 Column

第二层: fxLayer 特效层
烛焰跳动 + 蜡滴下落

第三层: 主内容 Column

弹窗状态判断

header 头部渲染

mainTab 值判断

首页: subNav + 子Tab内容

烛坊: pageCandle

课堂: pageCourse

我的: pageMine

subTab 值判断

pageFeatured 精选

pageCandle 蜡烛作品

pageRecipe 香调配方

pageMold 蜡材模具

pageCourse 烛艺课堂

pageCircle 烛友圈

pageStudio 工坊预约

bottomBar 底部导航渲染

addModalBody 新增弹窗

editModalBody 编辑弹窗

delModalBody 删除弹窗

正常交互状态

每90ms: tick自增
glow切换

flameX/flameY/flameS/flameA
重新计算烛焰粒子位置

dripY/dripA
重新计算蜡滴位置

fxLayer 局部刷新

用户点击底部Tab

mainTab 更新

用户点击子Tab

subTab 更新

点击发烛语/新建

openAdd 方法

addOpen = true

点击编辑

openEdit 方法
预填充数据

editOpen = true

点击下架/退课

openDelA/openDelB

delOpen = true

用户输入内容

onChange 更新状态变量

点击发布

doAdd 方法
postList.unshift

addOpen = false
关闭弹窗

用户编辑内容

onChange 更新 editName/Style/Note

点击保存

doEdit 方法
candleList.splice 替换

editOpen = false
关闭弹窗

点击确认

doDel 方法
candleList/postList.splice 删除

delOpen = false
关闭弹窗

用户退出应用

aboutToDisappear 生命周期

clearInterval 清除定时器

资源清理完成

五、技术对比表格

技术维度 实现方式 设计特点 性能考量
状态管理 @State + @Observed 细粒度响应式更新,属性级追踪 仅变更的 UI 片段刷新,避免全局重渲染
组件复用 @Builder 方法 轻量级 UI 片段复用,无独立生命周期 比独立 @Component 更省内存,适合静态片段
导航架构 主Tab + 子Tab 嵌套 条件渲染驱动页面切换 切换时重建组件树,内容适中时开销可接受
特效层 定时器 + 纯函数 + translate 90ms 间隔伪随机确定性动画 ~11FPS 低帧率省电,hitTestBehavior 不拦截点击
数据模型 @Observed class + interface 可变数据用 class,静态数据用 interface 静态数据不触发响应式追踪,减少运行时开销
列表渲染 ForEach + 键值生成器 基于 id/label 的稳定键值 键值稳定时仅更新属性,避免组件销毁重建
弹窗管理 状态开关 + 条件渲染 三弹窗共用遮罩层,统一交互 弹窗关闭即销毁组件,释放内存
颜色体系 interface + const 常量 语义化命名,集中管理 编译期类型检查,无运行时开销
状态-颜色映射 纯函数 switch/if 集中管理颜色编码逻辑 纯函数无副作用,可被安全缓存
双列布局 if(idx%2===0) + Row 手动分列,代码量较大但逻辑清晰 每行两个卡片,渲染数量减半
图表渲染 Stack 叠加 Row 底层轨道 + 上层填充的进度条 纯布局实现,无需图表库依赖
表单输入 TextInput + onChange 状态变量双向同步 每次输入触发状态更新,文本量小时开销极低
按钮实现 Button + 叠加 Text 负 margin 文字叠加 视觉自定义灵活,但布局依赖绝对偏移
滚动容器 Scroll + scrollBar Off 隐藏滚动条保持视觉整洁 layoutWeight 自适应高度
生命周期 aboutToAppear/Disappear 创建-使用-清理完整链路 定时器及时清理避免内存泄漏
数据操作 unshift/splice 数组方法 @State 数组的推荐变更方式 触发 ForEach 的增量更新而非全量重建
背景分层 Stack 四层叠加 背景-特效-内容-弹窗的清晰层级 特效层 None 模式不参与事件分发
圆角样式 borderRadius 统一 12-16px 圆角,卡片一致性 纯 CSS 属性,GPU 加速合成
间距体系 space + layoutWeight 弹性布局 + 固定间距混合 layoutWeight 自动分配剩余空间
文本截断 maxLines + textOverflow 两行省略号截断 避免长文本撑开卡片高度

六、详细总结

架构设计维度:烛语工坊应用采用了高度模块化的 Builder 驱动架构,将所有 UI 片段以 @Builder 方法的形式封装在单一 @Entry @Component 内,既保证了组件间的状态共享便利性(所有状态变量在同一作用域内),又通过 Builder 的复用能力避免了代码重复。四层 Stack 叠加的 build 方法结构——背景层、特效层、内容层、弹窗层——层次清晰、职责分明,每层的渲染时机和 z-index 层级通过声明顺序自然确定。主Tab+子Tab的嵌套导航架构,通过条件渲染(if-else 链)而非路由跳转实现页面切换,在单文件结构下是最简洁的方案。整体架构在"高内聚"和"简洁性"之间取得了良好平衡,适合原型到中小型应用的开发阶段。

状态管理维度:应用使用了十六个 @State 状态变量和四个 @Observed 数据模型类,构建了完整的响应式状态空间。状态变量按职责分组(导航、特效、弹窗、数据、定时器),命名采用前缀约定(add/edit/del),可读性强。@Observed 修饰的 PostItem 和 CandleItem 类,在 postList 和 candleList 被 @State 引用时,其 unshift/splice 操作会自动触发 ForEach 列表的增量更新,实现了数据驱动的 UI 同步。弹窗状态采用"开关布尔值+关联数据字段"的模式,openXxx 方法负责预填充和打开,doXxx 方法负责数据操作和关闭,形成了完整的"打开-操作-关闭"事务链。fxTimer 定时器状态的生命周期管理(aboutToAppear 创建、aboutToDisappear 清理)是避免资源泄漏的典范实践。

性能优化维度:应用在多个层面体现了性能意识。特效层采用 90 毫秒(约 11 FPS)的低帧率定时器,既保持了烛焰跳动的视觉效果,又大幅降低了 CPU 和电池消耗。纯函数计算粒子位置使用取模运算而非随机数,确保了确定性输出,避免了每帧重新分配内存。ForEach 的键值生成器使用稳定的 id 或 label 字符串,在 tick 变化时仅更新粒子属性而非销毁重建组件。hitTestBehavior(HitTestMode.None) 使特效层完全不参与触摸事件分发,消除了额外的命中测试开销。静态数据(SALE_CHART、SCENT_CHART、COURSE_LIST 等)使用 interface + const 而非 @Observed class,避免了不必要的响应式追踪开销。柱状图和进度条使用纯布局组件(Stack + Row)实现,无需引入图表库,减少了包体积和运行时依赖。

交互设计维度:应用的交互设计以"简洁直观"为核心原则。底部四Tab导航使用 Emoji 图标+文字标签,选中态通过透明度、字重和颜色三重视觉区分,反馈明确。子Tab导航条采用药丸形圆角标签,水平可滚动,选中态以主色背景高亮。弹窗交互统一采用"遮罩层点击关闭+底部按钮行"的模式,三个弹窗(新增/编辑/删除)的视觉结构和交互流程高度一致,降低了用户的学习成本。蜡烛作品列表的色条状态指示器(绿色=出品、金色=制作中)提供了快速扫描的视觉锚点。配方难度和蜡烛状态的颜色编码(绿/金/红三级)贯穿全应用,形成了统一的视觉语义系统。

可扩展性维度:应用的数据模型设计具有良好的扩展基础。@Observed class 的字段结构可以方便地增加新属性(如为 CandleItem 增加 price 字段),新属性会自动获得响应式追踪能力。颜色体系通过 ColorPalette 接口抽象,为未来支持多主题切换预留了空间——只需提供不同的 COLORS 实现即可。导航配置(NAV_LIST 和 SUB_NAV_LIST)以数据数组形式定义,增加新的主Tab或子Tab只需修改数组而非重构组件逻辑。纯函数(barH、styleColor、candleColor 等)的设计使得新增状态颜色映射或图表类型时,只需增加新的纯函数而不影响现有逻辑。弹窗管理模式可以轻松扩展第四种弹窗类型——增加 xxxOpen 状态变量、openXxx 方法、xxxModalBody Builder 和 doXxx 方法即可。Builder 驱动的架构使得新增页面只需增加一个 @Builder 方法并在 mainContent 的条件链中添加一个分支。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============ 烛语·手工蜡烛香氛工坊(884)============
// 场景:蜡烛作品 / 香调配方 / 烛艺课堂 / 烛友圈
// 底部4主tab(首页/烛坊/课堂/我的)+ 首页7内容tab
// 特效:烛焰跳动 + 蜡滴下落(特效层位于底层,不遮挡点击)
// 弹框:新增(发烛语) / 编辑(编辑配方) / 删除(下架蜡烛·退课)

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

const COLORS: ColorPalette = {
  primary: '#E8A84F',
  primaryLight: '#F0C47E',
  primaryDark: '#B87A2E',
  accent: '#D98AA4',
  accentLight: '#F5E4EA',
  bg: '#241812',
  cardBg: '#32241C',
  textPrimary: '#F7EDE0',
  textSecondary: '#C0A88F',
  textHint: '#8A7260',
  border: '#4A3526',
  success: '#8FBF7E',
  warning: '#E8A84F',
  danger: '#D96A5C',
  white: '#FFFFFF',
  flame: '#F5B25C'
};

// ============ 烛友动态数据模型 ============
@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 CandleItem {
  id: number = 0
  name: string = ''
  scent: string = ''
  wax: string = ''
  status: string = ''
  tag: string = ''
  constructor(id: number, name: string, scent: string, wax: string, status: string, tag: string) {
    this.id = id; this.name = name; this.scent = scent; this.wax = wax
    this.status = status; this.tag = tag
  }
}

// ============ 香调配方数据模型 ============
@Observed
export class RecipeItem {
  id: number = 0
  name: string = ''
  note: string = ''
  ratio: string = ''
  level: string = ''
  constructor(id: number, name: string, note: string, ratio: string, level: string) {
    this.id = id; this.name = name; this.note = note; this.ratio = ratio
    this.level = level
  }
}

// ============ 蜡材与模具数据模型 ============
@Observed
export class MoldItem {
  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 SaleChartItem {
  label: string;
  value: number;
}

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

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

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '烛光', '🕯️', '新调的大豆蜡「白檀夜雪」,烛芯偏短一点燃烧更稳定。', '9分钟前', 82),
  new PostItem(2, '蜡匠', '🧊', '冰花蜡出炉,冷却速度决定花纹,冰箱法成功率最高。', '25分钟前', 67),
  new PostItem(3, '香调师', '🌸', '樱花+白桃前调试香完成,甜而不腻,下周上架。', '1小时前', 74),
  new PostItem(4, '烛友', '✨', '第一次做果冻蜡海洋杯,贝壳贝壳摆太久有点移位。', '2小时前', 45),
  new PostItem(5, '温控王', '🌡️', '蜂蜡熔点 62-65°C,加温过头会发黄,分享测温笔记。', '3小时前', 58),
  new PostItem(6, '模具控', '🧁', '硅胶模具脱模前先冷冻 15 分钟,棱角一点不掉。', '5小时前', 51),
  new PostItem(7, '慢燃', '⏳', '测试 12 款烛芯,6cm 直径容器推荐 24 股棉芯。', '6小时前', 36),
  new PostItem(8, '夜烛', '🌙', '香薰蜡烛与助眠:薰衣草+雪松实测入睡快 20 分钟。', '昨天', 69)
];

const CANDLE_LIST: CandleItem[] = [
  new CandleItem(1, '白檀夜雪', '檀木·雪松', '大豆蜡', '出品', '助眠'),
  new CandleItem(2, '樱花白桃', '花果调', '混合蜡', '出品', '前调'),
  new CandleItem(3, '海洋果冻', '海盐·清新', '果冻蜡', '制作中', '景观'),
  new CandleItem(4, '蜜蜡蜂巢', '蜂蜜·花香', '蜂蜡', '出品', '天然'),
  new CandleItem(5, '琥珀光', '琥珀·麝香', '石蜡', '待脱模', '复古'),
  new CandleItem(6, '苔原雪松', '木质调', '大豆蜡', '出品', '中性'),
  new CandleItem(7, '冰花杯', '无香·装饰', '棕榈蜡', '制作中', '结晶'),
  new CandleItem(8, '月光浴', '茉莉·白茶', '椰子蜡', '评审', '限定')
];

const RECIPE_LIST: RecipeItem[] = [
  new RecipeItem(1, '白檀夜雪', '檀木 40% · 雪松 35% · 香根 25%', '8%', '进阶'),
  new RecipeItem(2, '樱花白桃', '樱花 50% · 白桃 50%', '6%', '入门'),
  new RecipeItem(3, '苔原雪松', '雪松 60% · 苔藓 25% · 琥珀 15%', '9%', '进阶'),
  new RecipeItem(4, '月光浴', '茉莉 45% · 白茶 40% · 晚香玉 15%', '7%', '入门'),
  new RecipeItem(5, '琥珀光', '琥珀 55% · 麝香 30% · 香草 15%', '10%', '大师'),
  new RecipeItem(6, '蜂蜜花香', '蜂蜡本香 · 玫瑰 30%', '5%', '入门')
];

const MOLD_LIST: MoldItem[] = [
  new MoldItem(1, '硅胶模具', '耐温 200°C', '🧁'),
  new MoldItem(2, '玻璃蜡杯', '6cm · 耐热', '🥃'),
  new MoldItem(3, '棉质烛芯', '24 股 · 45mm', '🕯️'),
  new MoldItem(4, '蜂蜡片', '纯天然 A 级', '🐝'),
  new MoldItem(5, '大豆蜡', '熔点 48°C', '🫘'),
  new MoldItem(6, '果冻蜡', '透明 · 弹性', '🪼'),
  new MoldItem(7, '香精套装', '12 款 · 10ml', '🌸'),
  new MoldItem(8, '融蜡锅', '双层 · 温控', '🍲')
];

const SALE_CHART: SaleChartItem[] = [
  { label: '周一', value: 16 },
  { label: '周二', value: 22 },
  { label: '周三', value: 19 },
  { label: '周四', value: 30 },
  { label: '周五', value: 42 },
  { label: '周六', value: 55 },
  { label: '周日', value: 48 }
];

const SCENT_CHART: ScentChartItem[] = [
  { label: '木质', value: 85, color: '#E8A84F' },
  { label: '花香', value: 70, color: '#D98AA4' },
  { label: '果香', value: 55, color: '#F0C47E' },
  { label: '草本', value: 42, color: '#8FBF7E' },
  { label: '海洋', value: 30, color: '#8FB4D4' }
];

const COURSE_LIST: CourseItem[] = [
  { title: '蜡烛入门课', week: '第 1-2 周', progress: 96, color: '#E8A84F' },
  { title: '香调基础调配', week: '第 2-4 周', progress: 72, color: '#D98AA4' },
  { title: '果冻蜡与景观蜡', week: '第 3-5 周', progress: 48, color: '#8FB4D4' },
  { title: '模具与脱模工艺', week: '第 5-7 周', progress: 29, color: '#8FBF7E' },
  { title: '调香与复配进阶', week: '第 6-8 周', progress: 13, color: '#E8A84F' }
];

// ============ 导航配置 ============
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(120 * v / max);
}

function styleColor(v: number): string {
  if (v > 70) {
    return '#E8A84F';
  } else if (v > 50) {
    return '#D98AA4';
  }
  return '#8FBF7E';
}

function flameX(tick: number, i: number): number {
  return 30 + ((tick * 4 + i * 89) % 600);
}

function flameY(tick: number, i: number): number {
  return 80 + ((tick * 2 + i * 47) % 340);
}

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

function flameA(tick: number): number {
  return tick % 2 === 0 ? 0.9 : 0.5;
}

function dripY(tick: number, i: number): number {
  return 420 - ((tick * 3 + i * 61) % 360);
}

function dripA(tick: number, i: number): number {
  return (i + tick) % 4 === 0 ? 0.8 : 0.3;
}

function candleColor(s: string): string {
  if (s === '出品' || s === '评审') {
    return '#8FBF7E';
  } else if (s === '制作中' || s === '待脱模') {
    return '#E8A84F';
  }
  return '#D96A5C';
}

function levelColor(s: string): string {
  if (s === '入门') {
    return '#8FBF7E';
  } else if (s === '进阶') {
    return '#E8A84F';
  }
  return '#D96A5C';
}

@Entry
@Component
struct PageCandle {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'candle'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State candleList: CandleItem[] = CANDLE_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.candleList.length) {
      this.editIdx = index;
      this.editName = this.candleList[index].name;
      this.editStyle = this.candleList[index].scent;
      this.editNote = this.candleList[index].wax;
    }
    this.editOpen = true;
  }

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

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

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

  doEdit(): void {
    if (this.editIdx >= 0 && this.editIdx < this.candleList.length) {
      this.candleList.splice(this.editIdx, 1, new CandleItem(this.candleList[this.editIdx].id, this.editName, this.editStyle, this.editNote, '制作中', '新品'));
    }
    this.editOpen = false;
  }

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

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2, 3, 4, 5, 6, 7], (i: number) => {
        Column()
          .width(4)
          .height(flameS(this.tick, i) * 2)
          .borderRadius(4)
          .backgroundColor(COLORS.flame)
          .opacity(flameA(this.tick))
          .translate({ x: flameX(this.tick, i), y: flameY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
        Column()
          .width(3)
          .height(8)
          .borderRadius(2)
          .backgroundColor('#F0C47E')
          .opacity(dripA(this.tick, i))
          .translate({ x: 50 + i * 88, y: dripY(this.tick, i) })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Row() {
      Column() {
        Text('烛语工坊')
          .fontSize(21)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('蜡烛作品 · 香调配方 · 烛艺课堂')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Text('🕯️')
        .fontSize(24)
      Text('🧊')
        .fontSize(22)
        .margin({ left: 10 })
    }
    .width('100%')
    .padding({ top: 8, bottom: 8 })
  }

  @Builder
  subNav() {
    Scroll() {
      Row({ space: 8 }) {
        ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
          Text(item)
            .fontSize(13)
            .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
            .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
            .padding({ left: 14, right: 14, top: 7, bottom: 7 })
            .borderRadius(16)
            .onClick(() => {
              this.subTab = idx;
            })
        }, (item: string) => item)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .padding({ top: 4, bottom: 10 })
  }

  @Builder
  pageFeatured() {
    Column({ space: 12 }) {
      // 周销量柱状大卡
      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(SALE_CHART, (it: SaleChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(22)
                .height(barH(it.value, 55))
                .borderRadius(6)
                .backgroundColor(styleColor(it.value))
              Text(it.label)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }, (it: SaleChartItem) => it.label + it.value.toString())
        }
        .width('100%')
        .alignItems(VerticalAlign.Bottom)
        .height(150)
        .margin({ top: 12 })
        Row({ space: 6 }) {
          Text('周均销量')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('33.1 百支')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 香调占比
      Column() {
        Text('香调占比')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
        ForEach(SCENT_CHART, (it: ScentChartItem) => {
          Row({ space: 8 }) {
            Text(it.label)
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .width(56)
            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: ScentChartItem) => 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(this.candleList, (it: CandleItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < this.candleList.length) {
                Column({ space: 6 }) {
                  Text(it.name)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${it.scent} · ${it.wax}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                  Row({ space: 6 }) {
                    Text(it.status)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(candleColor(it.status))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.bg)
                      .borderRadius(6)
                    Text(it.tag)
                      .fontSize(10)
                      .fontColor(COLORS.textHint)
                  }
                  .width('100%')
                }
                .layoutWeight(1)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < this.candleList.length) {
                Column({ space: 6 }) {
                  Text(this.candleList[idx + 1].name)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${this.candleList[idx + 1].scent} · ${this.candleList[idx + 1].wax}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                  Row({ space: 6 }) {
                    Text(this.candleList[idx + 1].status)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(candleColor(this.candleList[idx + 1].status))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.bg)
                      .borderRadius(6)
                    Text(this.candleList[idx + 1].tag)
                      .fontSize(10)
                      .fontColor(COLORS.textHint)
                  }
                  .width('100%')
                }
                .layoutWeight(1)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: CandleItem) => 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 })
              Row() {
                Text(`👍 ${it.likes}`)
                  .fontSize(11)
                  .fontColor(COLORS.textHint)
              }
              .width('100%')
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  pageCandle() {
    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.candleList, (it: CandleItem, idx: number) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(it.status === '出品' ? COLORS.success : COLORS.warning)
            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(candleColor(it.status))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.bg)
                  .borderRadius(6)
              }
              .width('100%')
              Row({ space: 8 }) {
                Text(`🌸 ${it.scent}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text(`🫧 ${it.wax}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
              }
              .width('100%')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          Row({ space: 10 }) {
            Text('编辑')
              .fontSize(11)
              .fontColor(COLORS.primary)
              .onClick(() => {
                this.openEdit(idx);
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.End)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: CandleItem) => it.id.toString())
      Button()
        .width('100%')
        .height(42)
        .backgroundColor(COLORS.danger)
        .borderRadius(10)
        .opacity(0.9)
        .onClick(() => {
          this.openDelA();
        })
      Text('下架蜡烛')
        .fontSize(14)
        .fontColor(COLORS.white)
        .margin({ left: -70, top: -32 })
    }
    .width('100%')
  }

  @Builder
  pageRecipe() {
    Column({ space: 10 }) {
      Text('香调配方')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(RECIPE_LIST, (it: RecipeItem) => {
        Column({ space: 6 }) {
          Row() {
            Text(it.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .layoutWeight(1)
            Text(it.level)
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor(levelColor(it.level))
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.bg)
              .borderRadius(6)
          }
          .width('100%')
          Text(it.note)
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
          Row({ space: 8 }) {
            Text('香精浓度')
              .fontSize(11)
              .fontColor(COLORS.textHint)
            Text(it.ratio)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: RecipeItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageMold() {
    Column({ space: 10 }) {
      Text('蜡材模具')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(MOLD_LIST, (it: MoldItem, idx: number) => {
        if (idx % 2 === 0) {
          Row({ space: 10 }) {
            if (idx < MOLD_LIST.length) {
              Column({ space: 6 }) {
                Text(MOLD_LIST[idx].icon)
                  .fontSize(26)
                Text(MOLD_LIST[idx].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(MOLD_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 < MOLD_LIST.length) {
              Column({ space: 6 }) {
                Text(MOLD_LIST[idx + 1].icon)
                  .fontSize(26)
                Text(MOLD_LIST[idx + 1].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(MOLD_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: MoldItem) => 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
  pageStudio() {
    Column({ space: 10 }) {
      Text('工坊预约')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Column({ space: 6 }) {
        Row() {
          Text('果冻蜡海洋杯')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('🕘 明日 14:00')
            .fontSize(11)
            .fontColor(COLORS.warning)
        }
        .width('100%')
        Row({ space: 12 }) {
          Text('工位:C-2')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('时长:2 小时')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .width('100%')
        Text('含材料包:果冻蜡 200g + 贝壳装饰')
          .fontSize(12)
          .fontColor(COLORS.primary)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
      Column({ space: 6 }) {
        Row() {
          Text('冰花蜡体验')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('🧊 周六 10:00')
            .fontSize(11)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        Row({ space: 12 }) {
          Text('场地:蜡艺室')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('人数:限 8 人')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .width('100%')
        Text('讲解冷却结晶原理,可带回成品杯')
          .fontSize(12)
          .fontColor(COLORS.textSecondary)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
      Column({ space: 10 }) {
        Row({ space: 10 }) {
          Column({ space: 6 }) {
            Text('🍲')
              .fontSize(26)
            Text('融蜡台')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('双层 · 温控')
              .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)
          Column({ space: 6 }) {
            Text('🌡️')
              .fontSize(26)
            Text('调香台')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('12 款香精')
              .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%')
        Row({ space: 10 }) {
          Column({ space: 6 }) {
            Text('🧁')
              .fontSize(26)
            Text('脱模台')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('冷冻 · 快速')
              .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)
          Column({ space: 6 }) {
            Text('🖼️')
              .fontSize(26)
            Text('展售架')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('代售成品')
              .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%')
      }
      .width('100%')
    }
    .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)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: PostItem) => it.id.toString())
    }
    .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('Lv.15 · 制烛 96 支 · 调香 22 款')
            .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.candleList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.candleList.slice(0, 3), (it: CandleItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.status)
              .fontSize(11)
              .fontColor(candleColor(it.status))
          }
          .width('100%')
        }, (it: CandleItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的动态')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.postList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row() {
            Text(it.nick)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.time)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }
    .width('100%')
  }

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

  @Builder
  addModalBody() {
    Column({ space: 12 }) {
      Text('发布烛语')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '记录今天的制烛见闻…', text: this.addTitle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.addTitle = v;
        })
      TextArea({ placeholder: '补充细节:蜡材、香调、模具、配方…', text: this.addContent })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(90)
        .onChange((v: string) => {
          this.addContent = v;
        })
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.addOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .onClick(() => {
            this.doAdd();
          })
        Text('发布')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  editModalBody() {
    Column({ space: 12 }) {
      Text('编辑蜡烛')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '蜡烛名称', text: this.editName })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.editName = v;
        })
      TextInput({ placeholder: '香调(如 檀木·雪松)', text: this.editStyle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.editStyle = v;
        })
      TextArea({ placeholder: '蜡材与工艺说明', text: this.editNote })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(70)
        .onChange((v: string) => {
          this.editNote = v;
        })
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.editOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .onClick(() => {
            this.doEdit();
          })
        Text('保存')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  delModalBody() {
    Column({ space: 12 }) {
      Text(this.delTarget === 'candle' ? '下架蜡烛' : '退出烛艺课堂')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(this.delTarget === 'candle' ? '将从展售架下架首支蜡烛,确认执行?' : '将退出首门进行中的课程,确认执行?')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.delOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.danger)
          .borderRadius(8)
          .onClick(() => {
            this.doDel();
          })
        Text('确认')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  bottomBar() {
    Row() {
      ForEach(NAV_LIST, (it: NavItem, idx: number) => {
        Column({ space: 2 }) {
          Text(it.icon)
            .fontSize(20)
            .opacity(this.mainTab === idx ? 1 : 0.55)
          Text(it.label)
            .fontSize(10)
            .fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => {
          this.mainTab = idx;
        })
      }, (it: NavItem) => it.label)
    }
    .width('100%')
    .height(56)
    .backgroundColor('#3A2A1F')
    .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.pageCandle()
            } else if (this.subTab === 2) {
              this.pageRecipe()
            } else if (this.subTab === 3) {
              this.pageMold()
            } else if (this.subTab === 4) {
              this.pageCourse()
            } else if (this.subTab === 5) {
              this.pageCircle()
            } else {
              this.pageStudio()
            }
          }
          .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.pageCandle()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else if (this.mainTab === 2) {
        Scroll() {
          Column() {
            this.pageCourse()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else {
        Scroll() {
          Column() {
            this.pageMine()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      }
    }
    .width('100%')
    .height('100%')
  }

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


在这里插入图片描述

行业适配维度:烛语工坊应用在视觉设计和功能规划上与手工蜡烛香氛行业高度契合。深色暖调的配色方案(深褐色背景+金色主色+暖白文本)模拟了烛光下昏暗温馨的工坊氛围,flame 烛焰色的跳动特效进一步强化了行业场景的沉浸感。功能模块覆盖了蜡烛工坊的完整业务链路:蜡烛作品管理(状态追踪从制作中到出品)、香调配方库(成分配比和浓度参数)、蜡材模具采购(八种核心材料工具)、烛艺课程教学(五门进阶课程和进度跟踪)、烛友社区互动(动态分享和点赞)、工坊线下预约(工位和体验活动)。数据内容的专业性(蜂蜡熔点 62-65°C、24股棉芯推荐、香精浓度 5%-10% 等)体现了对行业知识的深入理解,使应用不仅是 UI 原型,更是行业解决方案的有效呈现。

工程实践维度:从工程实践角度审视,该应用代码体现了多个良好的开发习惯。纯函数与副作用分离——所有计算逻辑(位置、大小、颜色映射)抽取为无副作用的顶层函数,组件方法只负责状态变更。Builder 方法的命名采用 page + 功能名的约定(pageFeatured、pageCandle、pageRecipe 等),可读性强且便于检索。卡片样式遵循统一规范(cardBg 背景、12-14 像素 padding、12-14 像素圆角),避免了样式碎片化。文本层级通过字号和颜色的组合清晰区分(16号粗体标题、14号粗体名称、12-13号正文、10-11号辅助信息),信息架构一目了然。生命周期管理的完整性(定时器创建与清理配对)和状态操作的原子性(数据操作与弹窗关闭同步)是避免运行时异常的关键保障。这些工程实践虽然看似基础,但在复杂应用开发中是保持代码质量和稳定性的基石。

Logo

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

更多推荐