香品电商行业基于HarmonyOS ArkTS API 24 build() 方法是每个自定义组件的核心入口,它返回一个由 Column、Row、Stack、Scroll 等基础容器组合而成的组件树
一、技术背景
1.1 HarmonyOS 声明式 UI 框架
HarmonyOS 的声明式 UI 框架是华为为其分布式操作系统打造的新一代界面开发范式。与传统的命令式 UI 编程不同,声明式框架要求开发者以"描述界面应该长什么样"的方式来构建视图,而不是一步步地操作界面元素。框架内部通过状态驱动的方式自动完成从状态数据到视觉呈现的映射,当状态发生变化时,框架会自动进行差分更新(diff),仅刷新受影响的部分。这种机制极大降低了界面与数据之间的同步心智负担,使开发者可以专注于业务逻辑本身。
在 ArkUI 体系中,build() 方法是每个自定义组件的核心入口,它返回一个由 Column、Row、Stack、Scroll 等基础容器组合而成的组件树。框架通过编译期转换,将这种声明式 DSL 转化为高效的 C++ 渲染指令,在保证开发效率的同时兼顾运行性能。ForEach、if 等控制流也可以直接在 build 体系内使用,使列表渲染和条件渲染变得非常直观。
声明式框架还提供了丰富的基础组件库,包括文本(Text)、按钮(Button)、输入框(TextInput)、滚动容器(Scroll)、线性渐变(linearGradient)等。这些组件大多采用链式调用的属性设置方式,例如 Text().fontSize().fontColor().fontWeight(),这种风格与业界流行的声明式框架高度一致,降低了跨技术栈开发者的迁移成本。

1.2 ArkTS 语言特性
ArkTS 是在 TypeScript 基础上扩展而成的 HarmonyOS 应用开发语言。它在保留 TypeScript 类型系统、接口(interface)、类(class)、泛型等特性的同时,引入了一批面向声明式 UI 的语法糖与编译期约束。例如 @Entry、@Component、@State、@Builder、@Observed 等装饰器,是 ArkTS 区别于普通 TypeScript 的核心标识。
ArkTS 的类型系统比标准 TypeScript 更加严格,它要求状态变量在声明时必须给出初始值,数组与对象的可空性受到更严格的约束,目的是让编译器在编译期就能推断出组件的渲染结构,从而进行激进的优化。在本应用中,所有数据模型类的字段都明确给出了默认值(如 id: number = 0),这正是 ArkTS 严格模式下的必要写法。
此外,ArkTS 对 ForEach 的键值生成函数(第三个参数)有强制要求,这是为了保证列表项在状态变化时能被精确识别与复用,避免不必要的全量重建,从而提升长列表的渲染性能。本应用在每个 ForEach 调用中都提供了 keyGenerator,体现了对 ArkTS 性能契约的遵循。
1.3 HarmonyOS 生态与跨设备能力
HarmonyOS 的核心愿景是"一生万物,万物互联"。它通过分布式软总线、分布式数据管理、分布式任务调度等底层能力,让应用可以在手机、平板、智慧屏、车机、手表等多种设备上无缝流转。对于电商与内容类应用而言,这意味着同一套代码可以适配多种屏幕尺寸与交互形态——大屏展示更丰富的橱窗,小屏聚焦核心交易链路。
本应用虽然主要面向手机端,但其布局策略已经体现出多设备适配的思路:大量使用 layoutWeight(1) 进行弹性伸缩、使用百分比宽度与 vp/% 混合单位、卡片采用圆角与内边距而非固定像素。这些都是面向多设备的弹性布局实践。此外,应用在内容区域采用 Scroll 包裹,当屏幕尺寸变大或变小、内容溢出时可以自然滚动,不会破坏布局结构。
HarmonyOS 还提供了统一的安全基座与 Ability 框架,应用可以通过 UIAbility、ExtensionAbility 等能力组件接入系统的通知、卡片、服务提醒等通道。虽然本应用聚焦于单页面交互的深度打磨,但其组件化的 @Builder 抽象方式,已经为后续拆分到多 Ability、多卡片形态预留了空间。
1.4 @Observed 装饰器与状态管理机制
@Observed 是 ArkUI 状态管理体系中用于"可观察数据模型类"的装饰器。被 @Observed 修饰的类,其属性在被赋值时会被框架劫持,从而触发依赖该属性的组件进行局部刷新。这与 @State(组件内状态)、@Prop(父到子单向同步)、@Link(双向同步)、@Provide/@Consume(跨层级传递)等装饰器共同构成了 ArkUI 的多层状态管理阶梯。
本应用中的 ScentItem、ReviewItem、RecipeItem、SessionItem 四个数据模型类全部使用 @Observed 装饰。这意味着当某个香评的 likes 被增加、某个配方的 name 被修改、某个预约的 title 被更新时,对应卡片能够精准地局部刷新,而不必重建整页。这对于一个拥有七个内容 Tab、多个列表和弹窗的复杂应用来说,是性能与体验的双重保障。
需要注意的是,@Observed 主要追踪"第一层属性赋值",对数组本身的 push/splice 操作,需要配合 @State 的数组引用变化或 ArkUI 的数组刷新机制才能触发视图更新。本应用在 doAdd、doDel 中通过 this.reviews.push(...) 和 this.sessions.splice(...) 来操作数组,并依赖 @State reviews / @State sessions 的可观察性来驱动列表重渲染。
1.5 香品电商行业的技术特点与挑战
香品(香水、精油、香料)电商是一个兼具"内容消费"与"专业体验"双重属性的特殊垂类。一方面,消费者对香气的认知高度主观,难以仅凭图片做出购买决策,因此平台必须提供丰富的文字香评、调香师动态、留香指数、香调结构等专业内容来辅助决策;另一方面,香品本身具有强烈的服务属性——线下调香工坊、体验课程、私人定制等,是线上无法替代的环节,这就要求 App 不仅要做好商品展示,还要做好"工坊预约"、“配方管理”、"调香师社区"等 O2O 联动。
技术实现上,香品电商 App 面临三大挑战。第一,视觉氛围塑造:香气是"看不见"的商品,必须通过配色、渐变、粒子特效、emoji 图标等"氛围层"来传递嗅觉想象,这要求 UI 层具备丰富的视觉表达能力(渐变、动效、层级叠加)。第二,内容密度与层次:需要同时承载商品列表、用户香评、留香数据图表、配方手账、预约日历、社区动态等多类异构内容,需要清晰的 Tab 体系与卡片化设计。第三,多模态交互:从浏览到写香评、改约、删配方,交互链路复杂,需要健壮的状态管理与弹窗系统。本应用的代码正是围绕这三个挑战展开的。
二、项目概览
本应用是一款面向香品行业的电商 + 内容 + 服务一体化 App,应用名"香氛纪·调香工坊",定位为"调香工坊"品牌的官方客户端。它整合了香料电商、香评社区、调香配方管理、线下体验预约和调香师社区五大业务板块,以一个自包含的 ArkTS 页面承载了完整的首页体验。
从结构上看,应用由三大层次构成。顶层是"头部样式"区,采用香品电商风格的头部,包含定位栏(店铺名 + 营业状态)、搜索与购物车图标、今日主打香大卡(粉紫渐变背景)以及热门香料横向滚动条。头部不带动画,保持稳定的信息锚点。中层是"内容 Tab"区,首页下细分七个内容 Tab,按 4+3 两排排布:精选、香料库、香评榜、留香指数(第一排,玫瑰色高亮)、配方工坊、体验预约、调香师圈(第二排,丁香色高亮)。每个 Tab 对应一个独立的 @Builder 内容视图。底层是"底部主 Tab"区,包含首页、香料库、工坊、我的四个主 Tab。
特效层面,应用实现了一套"香气粒子扩散"特效层。通过 setInterval 每 700 毫秒驱动一个 tick 计数器和 bloom 布尔开关,使若干个彩色圆形粒子和花朵 emoji 在内容区随机位置漂浮、缩放、透明度交替变化,营造出"香气弥漫"的视觉氛围。该特效层覆盖内容区但不遮挡头部,且通过 hitTestBehavior(HitTestMode.None) 让点击事件穿透,不影响下层交互。
弹窗层面,应用实现了三套独立弹窗:新增香评弹窗(昵称 + 评论文本输入)、编辑体验预约弹窗(课程名称 + 日期输入)、删除确认弹窗(区分"取消预约"和"删除配方"两种场景)。三套弹窗均采用"遮罩层 + 弹窗体 + zIndex"的条件渲染方式,互斥显示,并支持点击遮罩关闭。
整体架构上,应用遵循"单页面多 Builder"的组织方式:一个 @Entry @Component struct Page 作为根容器,内部通过十余个 @Builder 方法分别构建头部、导航、各内容页、各主页面、特效层、各弹窗,最后在 build() 中以 Stack 叠加组装。数据层采用 @Observed 类模型 + @State 数组 + 全局写死数据的混合策略,工具函数(如配色映射、粒子坐标计算)以纯函数形式抽离到顶层。这种"大页面 + 多 Builder"的模式在中小型应用中能在保证功能完整的同时避免过度拆分组件带来的通信成本。
三、逐段代码分析
3.1 头部注释与功能蓝图
// 头部样式:香品电商头部(搜索栏 + 定位 + 今日主打香大卡 + 热门香料横滚)无动画
// 底部主tab:首页 / 香料库 / 工坊 / 我的(4个)
// 首页内容tab:精选 / 香料库 / 香评榜 / 留香指数 / 配方工坊 / 体验预约 / 调香师圈(7个,两排 4+3)
// 特效:香气粒子扩散(setInterval 驱动,仅覆盖内容区不遮挡头部)
// 弹框:新增香评 / 编辑体验预约 / 删除配方确认(if条件渲染 + constraintSize + zIndex)
文件最顶部以五行中文注释勾勒出整个应用的功能蓝图。这种"以注释做架构说明书"的做法在中小型单文件应用中非常实用——它让阅读者在进入代码细节之前,就能在脑海中建立起页面的空间结构模型。注释按"上至下"的空间顺序描述了五个层次:头部样式、底部主 Tab、首页内容 Tab、特效层、弹框层,每一层都用括号补充了关键实现要点(如"无动画"、“4个”、“7个,两排 4+3”、“setInterval 驱动”、“if条件渲染 + constraintSize + zIndex”)。
这种蓝图式注释的价值在于,它不仅是给后续维护者的导航,也是给编译器无关的"人肉文档"。它揭示了应用有意将视觉层(头部、Tab)、动态层(特效)、交互层(弹框)分离的设计意图。尤其是"仅覆盖内容区不遮挡头部"这一约束,说明开发者刻意让头部保持静态稳定,避免粒子动效干扰用户对店铺信息和定位信息的快速读取,体现了对信息层级的精细把控。
3.2 ColorPalette 接口与颜色字段约定
interface ColorPalette {
bg: string;
card: string;
card2: string;
title: string;
sub: string;
line: string;
rose: string;
lilac: string;
cream: string;
amber: string;
musk: string;
gold: string;
red: string;
green: string;
blue: string;
mask: string;
white: string;
}
应用以一个 interface ColorPalette 作为整个配色体系的类型契约。这个接口定义了 17 个颜色字段,覆盖了背景、卡片、次卡片、标题、副标题、分割线、品牌主色(玫瑰)、辅助色(丁香、奶油、琥珀、麝香、金)、功能色(红、绿、蓝)、遮罩与纯白。每一个字段名都带有强烈的语义,而非用 color1、color2 这种无意义的命名。
通过接口约束颜色字段,开发者获得了两重好处。第一,任何使用 ColorPalette 类型的对象,都必须完整提供这 17 个字段,TypeScript 的结构化类型系统会在编译期检查完整性,避免因漏定义某个颜色而导致运行时 undefined 渲染。第二,它让"配色体系"成为一个可替换的"主题对象"——理论上只要换一个符合该接口的对象,就能整体切换应用主题(例如推出"夜间版"或"节日版")。这种将配色抽象为契约的设计,是大型应用主题化能力的基础。
字段命名上也体现了行业语义。“rose(玫瑰)”、“lilac(丁香)”、“cream(奶油)”、“amber(琥珀)”、"musk(麝香)"这些名称本身就来自香水香调的词汇体系,使配色对象天然契合香品电商的行业气质,阅读代码时也能联想到对应的香气意境。
3.3 COLORS 常量配色实例
const COLORS: ColorPalette = {
bg: '#FBF6F8',
card: '#FFFFFF',
card2: '#F4EBF0',
title: '#4A3542',
sub: '#9A8492',
line: '#EDE0E8',
rose: '#C4708F',
lilac: '#9B7FBF',
cream: '#E8D5B8',
amber: '#C89A5B',
musk: '#8A6E82',
gold: '#C9A227',
red: '#C9564F',
green: '#6FA07E',
blue: '#6C8FB5',
mask: '#000000CC',
white: '#FFFFFF'
};
紧随接口之后,应用以 const COLORS 实例化了这份配色契约。整体色调走的是"暖粉紫"路线:背景 #FBF6F8 是带极淡粉调的米白,卡片 #FFFFFF 纯白以形成层次,次卡片 #F4EBF0 是浅粉灰,标题 #4A3542 是深棕紫,副标题 #9A8492 是中灰紫——这一套基础色构成了柔和、温润、略带复古的氛围,非常契合"调香工坊"的品牌调性。
品牌色 rose: '#C4708F' 是带灰调的玫瑰粉,饱和度适中不刺眼;lilac: '#9B7FBF' 是丁香紫,与玫瑰形成同色系延伸;amber: '#C89A5B' 和 gold: '#C9A227' 提供暖金点缀;功能色 red、green、blue 也都做了去饱和处理,避免高饱和的原色破坏整体氛围。特别值得注意的是 mask: '#000000CC',使用 8 位十六进制(末两位 CC 表示 80% 不透明度的黑色),用于弹窗遮罩,这是 ArkUI 中表达半透明遮罩的简洁写法。
整体上,这套配色不是简单地从设计稿取色,而是经过统一的明度与饱和度调校,形成"同源感"的色板。开发者将它们集中定义在一个常量对象中,使全应用的颜色引用都通过 COLORS.xxx 完成,避免了散落的魔法色值,也使未来调整品牌色变得一处修改全站生效。
3.4 ScentItem 香料数据模型
@Observed
export class ScentItem {
id: number = 0;
name: string = '';
family: string = '';
note: string = '';
price: string = '';
stock: number = 0;
origin: string = '';
hot: string = '';
icon: string = '';
constructor(id: number, name: string, family: string, note: string,
price: string, stock: number, origin: string, hot: string, icon: string) {
this.id = id; this.name = name; this.family = family; this.note = note;
this.price = price; this.stock = stock; this.origin = origin; this.hot = hot; this.icon = icon;
}
}
ScentItem 是"香料"这一核心实体的数据模型类。它被 @Observed 装饰,意味着它的每一个属性赋值都能被 ArkUI 框架劫持并触发依赖刷新。类中包含 9 个字段:id(主键)、name(香料名)、family(香调家族,如花香调、柑橘调)、note(在香调中的位置,如前调、中调、后调)、price(价格文本)、stock(库存数量)、origin(产地)、hot(热门标记,如火焰 emoji)、icon(图标 emoji)。
值得注意的是 price 和 stock 的类型选择。price 被定义为 string 而非 number,这是因为应用直接把价格格式化后的字符串(如 "¥398/5ml")存入模型,省去了在视图层做货币格式化的步骤——这是"展示优先"的数据建模策略,适用于以展示为主的页面。stock 则保留为 number,便于在需要时做库存判断与运算。hot 和 icon 都是 string,实际存的是 emoji 字符,这是用 emoji 充当轻量图标的常见技巧,避免了引入图片资源。
构造函数采用位置参数 + 单行赋值的紧凑写法,将九个参数依次赋给对应字段。@Observed 类要求所有字段在声明时给默认值(如 id: number = 0),这是 ArkTS 严格类型的要求,保证即使无参构造也不会出现 undefined。export 关键字使该类可被其他模块导入,体现了数据模型的可复用设计。
3.5 ReviewItem 香评数据模型
@Observed
export class ReviewItem {
id: number = 0;
nick: string = '';
avatar: string = '';
perfume: string = '';
score: number = 0;
text: string = '';
likes: number = 0;
time: string = '';
constructor(id: number, nick: string, avatar: string, perfume: string,
score: number, text: string, likes: number, time: string) {
this.id = id; this.nick = nick; this.avatar = avatar; this.perfume = perfume;
this.score = score; this.text = text; this.likes = likes; this.time = time;
}
}
ReviewItem 是"用户香评"的数据模型。它包含 8 个字段:nick(昵称)、avatar(头像 emoji)、perfume(所评香水名)、score(评分,浮点数)、text(评论文本)、likes(点赞数)、time(发布时间文本)。score 使用 number 类型并保留小数(如 9.2),是为了在视图层根据分数阈值上色——本应用中有 scoreColor(s) 纯函数根据分数返回不同颜色,所以分数必须是数值才能做比较运算。
likes 同样是 number,便于未来实现"点赞 +1"的交互。time 用 string 存储如 “2小时前”、“昨天” 这类相对时间文案,这是因为应用当前不涉及时间计算,直接展示相对文本即可。这种"按需建模"的思路在小而全的展示型应用中很常见:不追求"数据正确性",而追求"展示直接性"。avatar 用 emoji 代替图片 URL,与 ScentItem 的 icon 一脉相承,体现了全应用统一的"emoji 即图标"策略,大幅降低了资源管理成本。
@Observed 装饰保证了当用户在前端新增一条香评、或某条香评的点赞数变化时,对应卡片能局部刷新。例如 doAdd 方法向 this.reviews 数组 push 一个新的 ReviewItem,依赖该数组的 ForEach 列表会自动追加渲染新卡片,这就是 @Observed + @State 协同的威力。
3.6 RecipeItem 配方数据模型
@Observed
export class RecipeItem {
id: number = 0;
name: string = '';
theme: string = '';
drops: number = 0;
base: string = '';
steps: number = 0;
author: string = '';
level: string = '';
icon: string = '';
constructor(id: number, name: string, theme: string, drops: number,
base: string, steps: number, author: string, level: string, icon: string) {
this.id = id; this.name = name; this.theme = theme; this.drops = drops;
this.base = base; this.steps = steps; this.author = author; this.level = level; this.icon = icon;
}
}
RecipeItem 描述"调香配方"这一实体,共 9 个字段。其中 theme(主题,如"冬日花木")、base(基调香料组合,如"檀香+鸢尾")、drops(总滴数)、steps(步骤数)、author(作者/调香师)、level(难度等级,“入门”/“进阶”/“高级”)、icon(配方主题 emoji)。drops 和 steps 是数值,便于做"X 滴"、"Y 步"的文本拼接,也便于未来做排序或筛选。
level 字段是字符串枚举(仅取三个值),应用中有专门的 levelColor(lv) 纯函数根据难度返回对应颜色:高级用麝香色(深沉)、进阶用玫瑰色(醒目)、入门用绿色(亲和)。这种"字段值 → 视觉属性"的映射通过纯函数抽离,既保持了视图代码的简洁,也使难度色板可独立调整。@Observed 让用户在"我的配方手账"中删除某个配方时(doDel 方法对 this.recipes.splice),列表能即时收缩,体验流畅。
3.7 SessionItem 体验预约数据模型
@Observed
export class SessionItem {
id: number = 0;
title: string = '';
date: string = '';
hours: number = 0;
price: string = '';
quota: string = '';
teacher: string = '';
place: string = '';
status: string = '';
constructor(id: number, title: string, date: string, hours: number,
price: string, quota: string, teacher: string, place: string, status: string) {
this.id = id; this.title = title; this.date = date; this.hours = hours;
this.price = price; this.quota = quota; this.teacher = teacher; this.place = place; this.status = status;
}
}
SessionItem 描述"线下体验预约"这一 O2O 实体,共 9 个字段。date(日期,如 “2026-09-12”)、hours(时长,数值,便于 “X 小时” 拼接)、quota(名额文本,如 “余 6/10”)、teacher(授课调香师)、place(地点,如 “工坊一层·调香台 A”)、status(状态,“可预约”/“即将满员”)。status 是字符串枚举,视图层根据它是否等于"即将满员"来切换名额徽章的颜色(金色 vs 绿色)。
这个模型体现了"服务型电商"的数据特征:与纯商品不同,体验预约携带了"时间、地点、人数、老师"等多维服务属性,每一条记录都是一次具体的"线下事件"。@Observed 让用户在"改约"弹窗里修改 title 或 date 后(doEdit 方法直接给 sessions[idx].title 赋值),对应卡片的标题能即时刷新;在"取消预约"后(doDel 方法 splice),列表立即收缩。这种局部刷新能力让多卡片列表的交互保持丝滑。
3.8 留香图表数据与香调结构数据
interface LastChartItem {
perfume: string;
val: number;
color: string;
}
const lastCharts: LastChartItem[] = [
{ perfume: '晨雾柑橘', val: 4, color: '#C9A227' },
{ perfume: '玫瑰低语', val: 7, color: '#C4708F' },
{ perfume: '白茶时光', val: 6, color: '#9B7FBF' },
{ perfume: '乌木沉香', val: 10, color: '#8A6E82' },
{ perfume: '铃兰晨露', val: 5, color: '#6FA07E' },
{ perfume: '海盐鼠尾', val: 8, color: '#6C8FB5' },
{ perfume: '檀香奶昔', val: 9, color: '#C89A5B' }
];
interface NoteItem {
label: string;
pct: number;
color: string;
}
const noteList: NoteItem[] = [
{ label: '前调 · 柑橘/青草(15分钟)', pct: 90, color: '#C9A227' },
{ label: '中调 · 花香/果香(2小时)', pct: 72, color: '#C4708F' },
{ label: '后调 · 木质/麝香(8小时)', pct: 55, color: '#8A6E82' },
{ label: '尾迹 · 琥珀/香草(24小时)', pct: 30, color: '#C89A5B' }
];
这里定义了两组图表数据。lastCharts 是"单品留香实测"的柱状图数据,每项包含香水名、留香小时数(val)和对应柱体颜色。七款香水覆盖了从 4 小时(晨雾柑橘,挥发快)到 10 小时(乌木沉香,留香久)的留香光谱,颜色与各香水的香调家族呼应(柑橘金、玫瑰粉、木质紫等)。这套数据驱动了"留香指数"Tab 中的柱状图渲染。
noteList 是"一瓶香的四段旅程"的香调结构数据,描述了前调、中调、后调、尾迹四个阶段,每项包含标签文本、剩余百分比(pct,表示该阶段结束时香气仍可感知的强度)和阶段色。前调 90%(最强)、中调 72%、后调 55%、尾迹 30%,呈现典型的"香气挥发衰减曲线"。这套数据驱动了香调结构条形图的渲染。
这两组数据都用 interface 而非 @Observed class,因为它们是只读的展示型常量,不需要被运行时修改,用轻量的接口 + 对象字面量即可。这体现了数据建模的"按需装饰"原则:只有需要被观察、会被修改的数据才用 @Observed,只读常量用普通接口即可,减少运行时开销。
3.9 全局香料列表数据
const scentList: ScentItem[] = [
new ScentItem(1, '大马士革玫瑰精油', '花香调', '中调', '¥398/5ml', 18, '保加利亚', '🔥', '🌹'),
new ScentItem(2, '佛手柑冷压油', '柑橘调', '前调', '¥128/10ml', 42, '意大利', '🔥', '🍊'),
new ScentItem(3, '檀香木精油', '木质调', '后调', '¥458/5ml', 9, '印度迈索尔', '🔥', '🪵'),
new ScentItem(4, '铃兰净油', '花香调', '中调', '¥688/2ml', 4, '法国格拉斯', '', '🪻'),
new ScentItem(5, '海盐 Accord', '海洋调', '前调', '¥88/10ml', 55, '实验室合成', '', '🧂'),
new ScentItem(6, '白麝香', '麝香调', '后调', '¥158/10ml', 31, '实验室合成', '🔥', '🤍'),
new ScentItem(7, '香草醛', '美食调', '尾迹', '¥98/10g', 48, '马达加斯加', '', '🌿'),
new ScentItem(8, '鸢尾根黄油', '粉感调', '中调', '¥928/2ml', 3, '意大利', '', '🪷'),
new ScentItem(9, '广藿香精油', '木质调', '后调', '¥138/10ml', 27, '印尼', '', '🍂'),
new ScentItem(10, '葡萄柚冷压油', '柑橘调', '前调', '¥68/10ml', 63, '美国佛州', '', '🍈'),
new ScentItem(11, '乳香精油', '树脂调', '后调', '¥218/5ml', 15, '阿曼', '', '🕯️'),
new ScentItem(12, '茉莉净油', '花香调', '中调', '¥568/2ml', 6, '埃及', '', '🤍')
];
scentList 是应用的香料主数据,以 12 条记录覆盖了花香、柑橘、木质、海洋、麝香、美食、粉感、树脂等主要香调家族。每条记录都携带了产地(保加利亚、意大利、印度迈索尔、法国格拉斯、马达加斯加、印尼、美国、阿曼、埃及),这些真实的产地信息极大地增强了内容的专业感与可信度,是香品电商"内容即信任"策略的体现。
价格区间从 ¥68/10ml(葡萄柚)到 ¥928/2ml(鸢尾根黄油),跨度极大,反映了天然香料的真实价格梯度——稀有净油(铃兰、鸢尾、茉莉)按毫升计价且昂贵,合成或冷压油(海盐、葡萄柚)则亲民。库存数也呼应了稀缺性:鸢尾根仅 3、铃兰仅 4,营造"限量珍稀"的紧迫感。hot 字段标记了 4 款热门香料(玫瑰、佛手柑、檀香、白麝香)加火焰 emoji,用于在列表中突出展示。
这份数据被多处复用:头部热门横滚、香料库宫格、香料库主页面。它是只读全局常量(const),不需要被修改,因此直接用 ScentItem 实例数组即可,无需 @State。这种"全局只读常量 + 组件内可变状态"的分层,是 ArkUI 数据管理的常见模式。
3.10 香评、配方与预约列表数据
const reviewList: ReviewItem[] = [
new ReviewItem(1, '雾里看花', '🌸', '玫瑰低语', 9.2, '开场是带露水的青玫瑰,半小时后粉感收拢,像捧着一束还没拆封的花。', 320, '2小时前'),
new ReviewItem(2, '木星来客', '🪐', '乌木沉香', 9.6, '乌木+沉香+一丝烟熏,气场全开,冬天大衣上能留两天,回头率超高。', 508, '5小时前'),
// ... 其余香评
];
const recipeList: RecipeItem[] = [
new RecipeItem(1, '初雪的庭院', '冬日花木', 42, '檀香+鸢尾', 6, '白露老师', '进阶', '❄️'),
new RecipeItem(2, '六月果园', '夏日果香', 30, '葡萄柚+香草', 4, '小满', '入门', '🍑'),
// ... 其余配方
];
const sessionList: SessionItem[] = [
new SessionItem(1, '调香入门体验 90 分钟', '2026-09-12', 2, '¥328', '余 6/10', '白露老师', '工坊一层·调香台 A', '可预约'),
new SessionItem(2, '专属 signature 香定制', '2026-09-18', 4, '¥1280', '余 2/4', '白露老师', '工坊二层·私享室', '即将满员'),
// ... 其余预约
];
这里集中定义了三份业务列表数据。reviewList 包含 8 条香评,每条都带有生动的嗅觉描述(“带露水的青玫瑰”、“乌木+沉香+一丝烟熏”),评分从 8.2 到 9.6,点赞数从 156 到 508。这些"有温度的文案"是香评社区的灵魂,让数据不只是数据,而是有故事的内容。reviewList 作为只读全局常量,用于"精选 Tab 的本周 TOP5"和"香评榜"的部分展示。
recipeList 包含 6 条配方,覆盖了冬日、夏日、湿润、纸张、美食、茶香等多元主题,难度从入门到高级,作者对应了三位虚构调香师(白露老师、小满、墨水、甜度超标)。sessionList 包含 5 条预约,从 ¥258 的亲子课到 ¥1280 的 signature 定制,地点覆盖工坊一层与二层,体现了"分层服务"的产品结构。其中"专属 signature 香定制"标记为"即将满员",名额仅余 2/4,制造稀缺感。
这三份数据的写法一致:用 new XxxItem(...) 实例化,每条一行紧凑排列。它们是只读常量,但在组件内部会被"复制"一份到 @State 数组中(如 @State sessions、@State recipes、@State reviews),以便支持新增、编辑、删除等运行时操作。这种"全局模板 + 组件内副本"的设计,既保证了初始数据的统一来源,又允许组件局部修改而不污染全局。
3.11 底部导航常量
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🌸', label: '首页' },
{ icon: '🧪', label: '香料库' },
{ icon: '🕯️', label: '工坊' },
{ icon: '👤', label: '我的' }
];
底部主 Tab 的数据被抽离为一个 NavItem 接口和 NAV_LIST 常量数组。NavItem 只有两个字段:icon(emoji 图标)和 label(文字标签)。四个 Tab 分别是首页(玫瑰)、香料库(试管)、工坊(蜡烛)、我的(人像),emoji 选择与业务语义高度契合。
将导航项定义为常量数组而非硬编码在 @Builder 中,有两个好处。第一,bottomBar 构建器可以通过 ForEach(NAV_LIST, ...) 遍历生成,代码量大幅减少,四个 Tab 的结构完全统一,只是数据不同。第二,未来若要增加或调整 Tab(例如增加"购物车"或"消息"),只需修改这个常量数组,不必改动构建器逻辑。这是"数据驱动 UI"的典型实践。
ForEach 的键值生成函数返回 ni.label,用标签文字作为唯一键。由于四个标签互不相同,这保证了每个 Tab 项在状态变化时(如选中态切换)能被精确识别与复用,符合 ArkUI 对 ForEach 键值唯一性的要求。点击某个 Tab 时,onClick 同时更新 mainTab(主 Tab 索引)和 subTab(内容 Tab 索引重置为 0),保证切换主 Tab 后内容 Tab 回到第一个,避免残留状态。
3.12 顶层纯函数工具集
function lastBarH(v: number): string {
return (v * 16).toString() + 'vp';
}
function scoreColor(s: number): string {
if (s >= 9.3) { return COLORS.rose; }
if (s >= 8.8) { return COLORS.lilac; }
return COLORS.blue;
}
function levelColor(lv: string): string {
if (lv === '高级') { return COLORS.musk; }
if (lv === '进阶') { return COLORS.rose; }
return COLORS.green;
}
function scentX(seed: number, tick: number): number {
return 20 + ((seed * 47 + tick * 3) % 80);
}
function scentY(seed: number, tick: number): number {
return 10 + ((seed * 29 + tick * 2) % 84);
}
function scentR(tick: number): number {
return 3 + (tick % 4);
}
function noteW(v: number): string {
return v.toString() + '%';
}
应用在顶层定义了七个纯函数,它们是"无副作用、输入决定输出"的工具函数,承担了视图层所有的"值到视觉属性"映射。lastBarH(v) 将留香小时数转换为柱状图高度(v * 16 vp),是"数据值 → 几何尺寸"的映射。scoreColor(s) 将评分映射为颜色(≥9.3 玫瑰爆款、≥8.8 丁香推荐、其余蓝色中性),是"数值 → 语义色"的映射。levelColor(lv) 将难度映射为颜色,noteW(v) 将百分比数值转为 CSS 宽度字符串。
scnetX、scentY、scentR 三个函数是特效层"香气粒子"的坐标与半径生成器,它们以 seed(粒子种子)和 tick(时间计数)为输入,通过取模运算生成伪随机但确定的坐标和半径。这种"确定性伪随机"的好处是:同一 tick 下所有粒子位置确定可复现,不会出现帧间抖动;而随 tick 变化又能产生持续的流动感。% 80、% 84、% 4 这些模数控制了粒子的活动范围与周期。
将这类"映射逻辑"抽离为纯函数而非写在 build 内,有两个显著好处:第一,build 方法保持简洁,只负责"布局 + 调用",可读性高;第二,纯函数可被单元测试覆盖,未来若要调整评分色阶或粒子轨迹,只改函数即可,不影响视图结构。这是"关注点分离"原则在 ArkTS 中的实践。
3.13 Page 组件状态声明
@Entry
@Component
struct Page {
@State mainTab: number = 0;
@State subTab: number = 0;
@State tick: number = 0;
@State bloom: boolean = false;
// 弹框状态
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = -1;
@State delTarget: string = 'recipe';
@State editTitle: string = '';
@State editDate: string = '';
@State addNick: string = '';
@State addText: string = '';
// 列表
@State sessions: SessionItem[] = [...];
@State recipes: RecipeItem[] = [...];
@State reviews: ReviewItem[] = [...];
fxTimer: number = -1;
这是应用的根组件 Page,被 @Entry(标记为入口页)和 @Component(标记为自定义组件)双重装饰。组件内部通过 @State 声明了 13 个可观察状态变量,可按职责分为四组。
第一组是导航状态:mainTab(主 Tab 索引,0-3)和 subTab(首页内容 Tab 索引,0-6),控制整个页面的内容切换。第二组是特效状态:tick(时间计数,驱动粒子坐标)和 bloom(布尔开关,驱动粒子透明度交替),两者配合 setInterval 实现粒子动效。第三组是弹窗状态:addModal/editModal/delModal 三个布尔控制三套弹窗的显隐(互斥),editIdx 记录正在编辑/删除的列表索引,delTarget 区分删除目标是"recipe"还是"session",editTitle/editDate/addNick/addText 是弹窗内输入框的受控值。第四组是列表数据:sessions、recipes、reviews 三个 @State 数组,是组件内可变的业务数据副本。
fxTimer 不带 @State,因为它只是一个定时器 ID,不需要触发视图刷新,用普通成员变量即可。@State 的核心作用是"被框架劫持,变化时触发依赖刷新",因此只装饰那些"变化需要驱动 UI 更新"的变量。这种精准的状态标注,避免了不必要的观察开销,是 ArkUI 性能优化的基础。
3.14 列表数据的组件内初始化
@State sessions: SessionItem[] = [
new SessionItem(1, '调香入门体验 90 分钟', '2026-09-12', 2, '¥328', '余 6/10', '白露老师', '工坊一层·调香台 A', '可预约'),
new SessionItem(2, '专属 signature 香定制', '2026-09-18', 4, '¥1280', '余 2/4', '白露老师', '工坊二层·私享室', '即将满员'),
new SessionItem(3, '亲子香气启蒙课', '2026-09-21', 2, '¥258', '余 12/15', '小满', '工坊一层·亲子区', '可预约'),
new SessionItem(4, '木质调专题研习', '2026-10-05', 3, '¥688', '余 5/8', '墨水', '工坊二层·研习室', '可预约'),
new SessionItem(5, '情侣对香手作', '2026-10-18', 3, '¥928', '余 4/6', '白露老师', '工坊二层·私享室', '可预约')
];
@State recipes: RecipeItem[] = [ ... ];
@State reviews: ReviewItem[] = [ ... ];
三个 @State 数组在组件声明时直接用 new XxxItem(...) 字面量初始化,内容与全局常量 sessionList、recipeList、reviewList 基本一致(reviews 只取了前 5 条,是 reviewList 的子集)。这种做法看似"重复定义",实则有深意。
全局常量 xxxList 是"原始模板数据",代表从服务器拉取的初始态;而 @State 数组是"组件内可变副本",代表用户交互后的当前态。将两者分离,使得组件内的增删改不会污染全局模板,未来若要"恢复初始数据"或"对比变更",只需对比这两套数据即可。这种"模板与副本分离"的模式在状态管理中非常通用。
@State 数组的可观察性覆盖了"数组引用变化"和"元素属性变化"两种场景。当 doDel 执行 this.sessions.splice(this.editIdx, 1) 时,数组引用虽未变,但 ArkUI 对 @State 数组的 splice/push 等变异方法做了拦截,能触发依赖该数组的 ForEach 重新渲染。当 doEdit 执行 this.sessions[idx].title = newValue 时,由于 SessionItem 是 @Observed 类,属性赋值也会触发对应卡片的局部刷新。两层观察协同,保证了列表交互的即时反馈。
3.15 aboutToAppear 与 aboutToDisappear 生命周期
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.bloom = !this.bloom;
}, 700);
}
aboutToDisappear(): void {
clearInterval(this.fxTimer);
}
aboutToAppear 和 aboutToDisappear 是 ArkUI 自定义组件的生命周期回调。aboutToAppear 在组件创建后、build 执行前调用,适合做数据初始化和副作用启动;aboutToDisappear 在组件销毁前调用,适合做资源释放。
在 aboutToAppear 中,应用启动了一个 setInterval 定时器,每 700 毫秒执行一次:将 tick 自增 1,并将 bloom 取反。这两个 @State 变量的变化会驱动 fxLayer 特效层的重渲染——tick 改变粒子的坐标和半径(通过 scentX/scentY/scentR 纯函数),bloom 改变粒子的透明度(0.35 与 0.12 交替)。就这样,一个 700ms 的节拍器,驱动了整个"香气粒子弥漫"动效。选择 700ms 而非更短的 60ms(约 16fps)或 16ms(约 60fps),是在"动效流畅度"与"性能/电量消耗"之间的折中——香气扩散本就是缓慢的意象,慢节拍反而更贴合氛围。
aboutToDisappear 中调用 clearInterval(this.fxTimer) 清除定时器,是防止内存泄漏的关键。如果不清理,组件销毁后定时器仍会持续触发回调,尝试更新已销毁组件的状态,可能导致警告或崩溃。这种"成对的生命周期管理"是 ArkUI 副作用处理的标配。
3.16 弹窗开关工具函数
openAdd(): void {
this.addNick = '';
this.addText = '';
this.addModal = true;
this.editModal = false;
this.delModal = false;
}
openEdit(idx: number): void {
this.editIdx = idx;
this.editTitle = this.sessions[idx].title;
this.editDate = this.sessions[idx].date;
this.addModal = false;
this.editModal = true;
this.delModal = false;
}
openDelRecipe(idx: number): void {
this.delTarget = 'recipe';
this.editIdx = idx;
this.addModal = false;
this.editModal = false;
this.delModal = true;
}
openDelSession(idx: number): void {
this.delTarget = 'session';
this.editIdx = idx;
this.addModal = false;
this.editModal = false;
this.delModal = true;
}
这四个 openXxx 方法是三套弹窗的"打开入口",它们统一遵循一个模式:先重置输入字段,再设置目标弹窗为 true,同时把另外两个弹窗设为 false。这种"打开一个、关闭其他"的互斥逻辑,保证了任何时刻最多只有一个弹窗显示,避免弹窗叠加导致的视觉混乱。
openAdd 在打开新增香评弹窗前,清空了 addNick 和 addText,保证每次打开都是干净输入框。openEdit 接收一个索引参数,先把目标预约的 title 和 date 预填到 editTitle/editDate,实现"编辑即预填"的体验。openDelRecipe 和 openDelSession 都打开删除确认弹窗,但通过 delTarget 字段区分删除对象是配方还是预约,使同一个 delModalBody 能根据 delTarget 显示不同的确认文案和执行不同的删除逻辑——这是"一个弹窗服务多种场景"的复用设计。
这些方法被各内容页的按钮 onClick 调用,将"用户点击 → 弹窗打开"的交互链路封装为方法,使 onClick 回调保持简洁(如 () => { this.openEdit(i); })。这种将交互逻辑下沉到方法层的做法,使视图层与逻辑层分离,提升了可维护性。
3.17 doAdd / doEdit / doDel 业务操作
doAdd(): void {
if (this.addText.length > 0) {
const n = this.reviews.length + 1;
this.reviews.push(new ReviewItem(n, this.addNick.length > 0 ? this.addNick : '新鼻子', '🫧', '自由香评', 8.0, this.addText, 0, '刚刚'));
this.addModal = false;
}
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.sessions.length) {
this.sessions[this.editIdx].title = this.editTitle;
this.sessions[this.editIdx].date = this.editDate;
this.editModal = false;
}
}
doDel(): void {
if (this.delTarget === 'session') {
if (this.editIdx >= 0 && this.editIdx < this.sessions.length) {
this.sessions.splice(this.editIdx, 1);
this.delModal = false;
}
} else {
if (this.editIdx >= 0 && this.editIdx < this.recipes.length) {
this.recipes.splice(this.editIdx, 1);
this.delModal = false;
}
}
}
这三个 doXxx 方法是弹窗"确认"按钮的业务执行器,它们负责真正修改数据并关闭弹窗。doAdd 在 addText 非空时,构造一个新的 ReviewItem(id 取数组长度+1,昵称为空时默认"新鼻子",头像用泡泡 emoji,香水名标记"自由香评",评分 8.0,点赞 0,时间"刚刚"),push 进 reviews 数组,然后关闭弹窗。push 触发 @State reviews 的观察,ForEach 列表自动追加新卡片。
doEdit 先做索引越界保护(editIdx >= 0 && editIdx < length),再将 editTitle/editDate 赋值回 sessions[editIdx] 的对应字段。由于 SessionItem 是 @Observed,属性赋值触发对应卡片的标题/日期局部刷新。doDel 根据 delTarget 分流:若是"session"则从 sessions 数组 splice 删除对应项,否则从 recipes 数组 splice 删除。splice 触发数组观察,列表即时收缩。
三个方法都遵循"校验 → 修改 → 关闭弹窗"的三段式,且都在校验通过后才执行修改,保证了数据一致性。这种把"业务执行"从"视图回调"中剥离的做法,使逻辑可测试、可复用,是组件内聚职责的体现。
3.18 fxLayer 香气粒子特效层
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (s: number) => {
Column()
.width(scentR(this.tick) + s % 3)
.height(scentR(this.tick) + s % 3)
.borderRadius((scentR(this.tick) + s % 3) / 2)
.backgroundColor(s % 3 === 0 ? COLORS.rose : (s % 3 === 1 ? COLORS.lilac : COLORS.amber))
.opacity(this.bloom ? 0.35 : 0.12)
.position({ x: scentX(s, this.tick), y: scentY(s + 2, this.tick) })
}, (s: number) => s.toString())
ForEach([10, 11, 12], (s: number) => {
Text(s % 2 === 0 ? '🌸' : '✨')
.fontSize(9)
.opacity(this.bloom ? 0.8 : 0.2)
.position({ x: scentX(s, this.tick) + 10, y: scentY(s - 4, this.tick) })
}, (s: number) => s.toString())
}
.width('100%')
.height('100%')
.clip(true)
}
fxLayer 是应用的视觉点睛之笔——"香气粒子扩散"特效。它由两组 ForEach 构成:第一组生成 10 个圆形粒子(种子 0-9),第二组生成 3 个花朵/闪光 emoji(种子 10-12),共 13 个浮动元素,全部叠放在一个 Stack 容器中。
每个圆形粒子的尺寸由 scentR(this.tick) + s % 3 决定:scentR 返回 3-6 的基础半径(随 tick 在 3/4/5/6 间循环),再叠加 s % 3(0/1/2)的个体差异,使 10 个粒子大小各异且整体随时间脉动。borderRadius 取尺寸的一半,形成正圆。颜色按 s % 3 三色轮换(玫瑰、丁香、琥珀),与品牌色板呼应。透明度由 this.bloom 二值切换(0.35 显 / 0.12 隐),每 700ms 交替,形成"呼吸感"。position 用 scentX/scentY 计算的坐标定位,坐标随 tick 变化,粒子便"飘动"起来。.clip(true) 确保 Stack 内容不溢出容器,粒子被裁剪在内容区内。
第二组 emoji 粒子用 Text 渲染花朵或闪光,fontSize(9) 很小,透明度在 0.8/0.2 间切换,比圆形粒子更"闪烁"。三组粒子种子(10/11/12)的坐标用 scentX(s, tick) 和 scentY(s-4, tick) 计算,s-4 使 y 坐标偏移,与圆形粒子错落分布。整个特效层在 build 中被一个 hitTestBehavior(HitTestMode.None) 的 Column 包裹,让点击穿透,不干扰下层交互。
3.19 header 香品电商头部构建器
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Column() { Text('📍').fontSize(15) }
.width(32).height(32).borderRadius(16).backgroundColor(COLORS.card2)
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Text('香氛纪·调香工坊').fontSize(11).fontColor(COLORS.sub)
Text('上海安福路店 · 今日营业').fontSize(9).fontColor(COLORS.rose)
}.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Row({ space: 6 }) {
Column() { Text('🔍').fontSize(14) }
.width(30).height(30).borderRadius(15).backgroundColor(COLORS.card2)
.justifyContent(FlexAlign.Center)
Column() { Text('🛍️').fontSize(14) }
.width(30).height(30).borderRadius(15).backgroundColor(COLORS.card2)
.justifyContent(FlexAlign.Center)
}
}.width('100%')
Row({ space: 12 }) {
Column({ space: 3 }) {
Text('秋日主打').fontSize(10).fontColor('#F3E8EF')
Text('雨后苔原 No.7').fontSize(17).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('广藿香 × 海盐 × 湿青苔 · 限量 100 瓶').fontSize(9).fontColor('#E8D5E0')
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column({ space: 5 }) {
Text('🌿').fontSize(34)
Text('¥728/50ml').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
}.alignItems(HorizontalAlign.End)
}
.width('100%').padding(14).borderRadius(16)
.linearGradient({ angle: 120, colors: [['#C4708F', 0], ['#9B7FBF', 1]] })
header 是香品电商风格头部,分三段:定位搜索行、今日主打大卡、热门香料横滚。第一段是 Row 内含一个圆形定位图标(emoji 📍 居中于 32×32 圆角方块)、店铺名+营业状态双行文本、弹性占位 Column().layoutWeight(1)、搜索与购物车双图标。layoutWeight(1) 的空 Column 起到"弹簧"作用,把右侧图标推到行末,是 Flexbox 两端对齐的经典手法。
第二段"今日主打大卡"是头部视觉焦点,使用 linearGradient 从玫瑰色(#C4708F)到丁香色(#9B7FBF)的 120 度斜向渐变作背景,叠加白色文字。"秋日主打"小标用淡粉白 #F3E8EF,"雨后苔原 No.7"主标题 17 号粗体白,副标"广藿香 × 海盐 × 湿青苔 · 限量 100 瓶"用浅粉 #E8D5E0。右侧大号🌿 emoji 和白色价格构成视觉锚点。整张卡圆角 16、内边距 14,是电商头图的标准规格。
这段渐变卡是"氛围层"的集中体现——用品牌色渐变 + 大字 + emoji,把"看不见的香气"转化为"看得见的情绪"。120 度斜向渐变让光感从左下到右上,营造出"晨光中的香氛"意象。注释明确标注"无动画",说明头部刻意保持静态,作为用户每次回到首页时的稳定视觉锚点,不被粒子动效干扰。
3.20 header 热门香料横滚
// 热门香料横滚
Scroll() {
Row({ space: 10 }) {
ForEach(scentList, (s: ScentItem) => {
Column({ space: 4 }) {
Text(s.icon).fontSize(18)
Text(s.name.length > 6 ? s.name.slice(0, 6) : s.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(s.note).fontSize(8).fontColor(COLORS.rose)
}
.width(88).padding({ top: 8, bottom: 8 }).borderRadius(10)
.backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
}, (s: ScentItem) => s.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(74)
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.bg)
}
头部第三段是热门香料横向滚动条。Scroll 包裹一个 Row,Row 内用 ForEach(scentList) 遍历 12 个香料,每个香料是一个 88px 宽的 Column 卡片,含 emoji 图标(18 号)、香料名(9 号,超 6 字截断)、香调位置(8 号玫瑰色)。卡片背景用 COLORS.card2 浅粉灰,圆角 10,上下内边距 8,整体高度 74。
scrollable(ScrollDirection.Horizontal) 启用横向滚动,scrollBar(BarState.Off) 隐藏滚动条,保持视觉干净——这是电商"瀑布流/横滚橱窗"的标配。香料名用 s.name.length > 6 ? s.name.slice(0, 6) : s.name 做了前端截断,防止长名(如"大马士革玫瑰精油"是 9 字)撑破卡片宽度,是响应式文本处理的典型手段。ForEach 的键值用 s.id.toString(),保证每个香料卡片在列表中唯一可识别。
整个 header 被包在一个 Column({ space: 10 }) 中,三段之间有 10 的间距,整体内边距 12/8,背景色 COLORS.bg。这个头部是应用的"门面",集中了品牌色渐变、emoji 图标体系、横滚列表、定位与搜索入口,是"香品电商"行业特征的浓缩呈现。
3.21 subNav 内容 Tab 双排导航
@Builder
subNav() {
Column({ space: 6 }) {
Row({ space: 6 }) {
ForEach(['精选', '香料库', '香评榜', '留香指数'], (t: string, i: number) => {
Column() {
Text(t)
.fontSize(11)
.fontColor(this.subTab === i ? COLORS.white : COLORS.sub)
.fontWeight(this.subTab === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.subTab === i ? COLORS.rose : COLORS.card2)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.subTab = i; })
}, (t: string) => t)
}.width('100%')
Row({ space: 6 }) {
ForEach(['配方工坊', '体验预约', '调香师圈'], (t: string, i: number) => {
Column() {
Text(t)
.fontSize(11)
.fontColor(this.subTab === i + 4 ? COLORS.white : COLORS.sub)
.fontWeight(this.subTab === i + 4 ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.subTab === i + 4 ? COLORS.lilac : COLORS.card2)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.subTab = i + 4; })
}, (t: string) => t)
}.width('100%')
}
.width('100%')
.padding({ left: 12, right: 12 })
}
subNav 是首页的内容 Tab 导航,七个 Tab 分两排:第一排四个(精选、香料库、香评榜、留香指数),第二排三个(配方工坊、体验预约、调香师圈)。这种 4+3 布局是为了在有限宽度下让每个 Tab 按钮有足够点击区域,同时避免单排七按钮的拥挤。
两排 Tab 的视觉编码有微妙差异:第一排选中态背景为 COLORS.rose(玫瑰红),第二排选中态为 COLORS.lilac(丁香紫)。这种"两色两排"设计不仅是视觉区分,也暗示了功能分组——第一排偏"商品/数据展示"(精选、香料、香评、留香),第二排偏"创作/服务/社区"(配方、预约、圈层)。未选中态统一用 COLORS.card2 浅粉灰背景,文字用 COLORS.sub 灰紫。
onClick 回调将 subTab 设为对应索引:第一排直接用循环索引 i(0-3),第二排用 i + 4(4-6),使七个 Tab 共享一个 subTab 状态变量(0-6)。layoutWeight(1) 让每个按钮等宽分配,borderRadius(14) 给按钮圆润感。整个 subNav 的设计紧凑而清晰,是七内容页切换的统一入口。
3.22 tabPick 精选页:橱窗横滚与榜单
@Builder
tabPick() {
Scroll() {
Column({ space: 12 }) {
Scroll() {
Row({ space: 10 }) {
ForEach([0, 1, 2, 3], (b: number) => {
Column({ space: 6 }) {
Text(b === 0 ? '🌹 花香橱窗' : (b === 1 ? '🪵 木质橱窗' : (b === 2 ? '🍊 果香橱窗' : '🧂 咸香橱窗')))
.fontSize(14).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text(b === 0 ? '玫瑰/铃兰/茉莉' : (b === 1 ? '檀香/广藿香' : (b === 2 ? '佛手柑/葡萄柚' : '海盐/鼠尾草')))
.fontSize(8).fontColor('#F3E8EF')
Text(b % 2 === 0 ? '低至 8 折' : '买 3 送 1').fontSize(8)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8)
.backgroundColor('#FFFFFF33')
}
.width(150).height(90).padding(12).borderRadius(14)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
.backgroundColor(b === 0 ? '#C4708F' : (b === 1 ? '#8A6E82' : (b === 2 ? '#C89A5B' : '#6C8FB5')))
}, (b: number) => b.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
tabPick 是首页"精选"内容页,纵向滚动布局,包含三段:四色橱窗横滚、本周香评 TOP5、调香小知识。第一段是四个 150×90 的橱窗卡横向滚动,每张卡背景色不同(玫瑰红/麝香紫/琥珀金/海洋蓝),对应花香、木质、果香、咸香四个香调家族。卡内含橱窗名(emoji+文字)、代表香料列表、促销标签(“低至 8 折”/“买 3 送 1”,半透明白底 #FFFFFF33)。
这段橱窗是"香调分类入口"的视觉化呈现——用四种品牌色对应四类香调,让用户一眼就能按"嗅觉偏好"选择入口。嵌套的 Scroll(外纵向 + 内横向)是 ArkUI 常见的多向滚动组合,注意内外 Scroll 的滚动方向必须不同,否则手势冲突。促销标签用 backgroundColor('#FFFFFF33') 半透明白底,叠加在彩色卡面上形成"玻璃质感",是电商促销标签的常见做法。
ForEach 用 [0,1,2,3] 数组生成四个卡,键值用 b.toString()。每张卡的内容用三元表达式按 b 索引切换文案,这种"用索引做内容分支"的写法在小型写死数据场景下很简洁,适合橱窗数量固定的情况。
3.23 tabPick 本周榜单与调香小知识
Text('本周香评 TOP5').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(reviewList, (r: ReviewItem, i: number) => {
if (i < 5) {
Row({ space: 10 }) {
Text((i + 1).toString())
.fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(i === 0 ? COLORS.rose : (i === 1 ? COLORS.lilac : COLORS.sub))
.width(24)
Column({ space: 2 }) {
Text(r.perfume).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(r.nick + ' · ' + r.time).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(r.score.toFixed(1)).fontSize(12).fontColor(scoreColor(r.score)).fontWeight(FontWeight.Bold)
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}
}, (r: ReviewItem) => r.id.toString())
Text('调香小知识').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 10 }) {
Column({ space: 4 }) {
Text('⏱️').fontSize(20)
Text('前中后调').fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text('挥发速度决定层次').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
// ... 另两个知识卡
}
.width('100%')
精选页第二段是"本周香评 TOP5"榜单,用 ForEach(reviewList) 遍历全局香评数据,但用 if (i < 5) 只渲染前 5 条。每条是一个 Row:左侧名次数字(前三名分别用玫瑰/丁香/灰),中间香水名+昵称·时间,右侧评分(用 scoreColor 纯函数着色)。.toFixed(1) 保证评分显示一位小数。这是"用 ForEach + if 实现分页/截断"的典型写法。
第三段"调香小知识"是三个等宽知识卡(前中后调、酒精浓度、净油vs精油),每卡 layoutWeight(1) 等分宽度,含 emoji 图标、知识点标题、一句话说明。这是"教育型内容"的卡片化呈现,用极简的三卡向用户传递香水基础知识,既是内容增值,也是购买决策辅助。
整页用外层 Scroll 纵向包裹,内含橱窗横滚 + 榜单 + 知识,形成了"橱窗导购 → 排行参考 → 知识科普"的精选内容链路。这种"先吸引眼球(橱窗)、再建立信任(榜单)、最后教育用户(知识)"的编排,是电商内容运营的经典节奏。
3.24 tabScents 香料库四列宫格
@Builder
tabScents() {
Scroll() {
Column({ space: 10 }) {
Text('原料香料柜(' + scentList.length.toString() + ' 种在架)')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
ForEach(scentList, (s: ScentItem) => {
if (s.id % 4 === 1) {
Column({ space: 4 }) {
Text(s.icon).fontSize(22)
Text(s.name.length > 6 ? s.name.slice(0, 6) : s.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(s.family).fontSize(7).fontColor(COLORS.rose)
Text(s.price).fontSize(8).fontColor(COLORS.musk).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
}, (s: ScentItem) => s.id.toString())
}.width('100%')
// ... 其余三行(s.id % 4 === 2/3/0)
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
tabScents 是"香料库"内容页,以四列宫格展示全部 12 种香料。实现方式很巧妙:用四个 Row,每个 Row 内用同一个 ForEach(scentList) 遍历,但用 if (s.id % 4 === 1)、if (s.id % 4 === 2)、if (s.id % 4 === 3)、if (s.id % 4 === 0) 分别只渲染对应余数的香料。这样 id 为 1/5/9 的香料落在第一行,2/6/10 在第二行,3/7/11 在第三行,4/8/12 在第四行,形成整齐的四列三行宫格。
这种"用取模分行"的写法虽然朴素,但在没有 Grid 组件或不想引入复杂网格逻辑时,是一种可靠的宫格实现。每个香料卡 layoutWeight(1) 等宽,含 emoji 图标(22 号)、香料名(9 号截断)、香调家族(7 号玫瑰色)、价格(8 号麝香色粗体)。卡片背景 COLORS.card 白底,圆角 12,内边距 10。
标题"原料香料柜(12 种在架)"用 scentList.length.toString() 动态拼接数量,是"数据驱动文案"的小细节——未来香料增减,标题自动更新。整页纵向滚动,是"商品宫格"的标准电商浏览体验,让用户能快速扫视全部在售香料。
3.25 tabReviews 香评榜大卡流
@Builder
tabReviews() {
Scroll() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('香评榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Button().width(78).height(26).borderRadius(13).backgroundColor(COLORS.rose)
.onClick(() => { this.openAdd(); })
Text('+写香评').fontSize(9).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -64 })
}.width('100%')
ForEach(this.reviews, (r: ReviewItem) => {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(r.avatar).fontSize(24)
Column({ space: 2 }) {
Text(r.nick).fontSize(11).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text(r.time).fontSize(8).fontColor(COLORS.sub)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column({ space: 1 }) {
Text(r.score.toFixed(1)).fontSize(13).fontColor(scoreColor(r.score)).fontWeight(FontWeight.Bold)
Text(r.score >= 9.3 ? '口碑爆款' : (r.score >= 8.8 ? '值得一试' : '见仁见智')).fontSize(7).fontColor(COLORS.sub)
}.alignItems(HorizontalAlign.End)
}.width('100%')
Text('评 #' + r.perfume + '#').fontSize(9).fontColor(COLORS.lilac)
Text(r.text).fontSize(10).fontColor(COLORS.title).maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
Row({ space: 12 }) {
Text('🤍 ' + r.likes.toString()).fontSize(9).fontColor(COLORS.sub)
Text('💬 回应').fontSize(9).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text('再评一次').fontSize(8).fontColor(COLORS.blue)
}.width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (r: ReviewItem) => r.id.toString())
tabReviews 是"香评榜"内容页,顶部一个标题行 + "写香评"按钮,下方是 this.reviews 数组驱动的大卡流。每张香评卡含:头像+昵称+时间行、评分+口碑标签行、所评香水标签、评论文本(最多 3 行省略)、点赞+回应+再评操作行。这里遍历的是 @State reviews 而非全局 reviewList,所以用户新增的香评会追加到列表底部,删除的会消失——是真正可交互的列表。
"写香评"按钮的实现很特别:用一个 Button 组件 + 一个 Text 叠加。Button 设了玫瑰色背景和点击事件 openAdd,Text 用 margin({ left: -64 }) 负边距回退,叠在 Button 上显示文字。这种"按钮 + 负边距文字"的写法是为了绕开 Button 默认样式的限制,自由控制按钮内文字的字号和颜色(9 号白色粗体)。这是 ArkUI 中"用基础组件拼装自定义按钮"的常见技巧。
评论文本用 maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis }) 限制最多 3 行并省略号截断,保证长评不撑破卡片高度,列表视觉整齐。评分用 scoreColor 着色,口碑标签按分数阈值(9.3/8.8)分"口碑爆款/值得一试/见仁见智"三档,是"数值→语义文案"的映射。整页是典型的"社区内容流"形态。
3.26 tabLast 留香指数柱状图

@Builder
tabLast() {
Scroll() {
Column({ space: 12 }) {
Text('单品留香实测(小时)').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 10 }) {
ForEach(lastCharts, (c: LastChartItem) => {
Column({ space: 4 }) {
Text(c.val.toString() + 'h').fontSize(8).fontColor(c.color).fontWeight(FontWeight.Bold)
Column()
.width(20)
.height(lastBarH(c.val))
.borderRadius(6)
.backgroundColor(c.color)
Text(c.perfume.length > 4 ? c.perfume.slice(0, 4) : c.perfume).fontSize(7).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Center)
}, (c: LastChartItem) => c.perfume)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.alignItems(VerticalAlign.Bottom)
.height(230)
tabLast 是"留香指数"内容页,核心是一个纯 ArkUI 组件拼装的柱状图。数据来自全局 lastCharts(7 款香水 + 留香小时数 + 颜色)。每根柱子是一个 Column:顶部数值文本(如 “10h”)、中间柱体(Column 宽 20、高 lastBarH(val) 即 val*16 vp、圆角 6、背景色取数据色)、底部香水名(超 4 字截断)。七根柱子横向排列在 Row 中,alignItems(VerticalAlign.Bottom) 让所有柱子底对齐,形成标准柱状图形态。
这个柱状图的精妙之处在于完全用基础组件(Column + Row + 尺寸属性)实现,没有引入任何图表库。柱高通过纯函数 lastBarH(val) 将数据值线性映射为高度(val * 16 vp),4 小时→64vp,10 小时→160vp,比例合理。容器固定高 230vp,最高柱 160vp 留出顶部数值文本空间。这种"用布局组件模拟图表"的做法在轻量场景下性能好、可控性强,缺点是缺乏动画与交互(如点击柱子看详情),但对于展示型留香数据已足够。
柱体颜色直接取自数据的 color 字段,与各香水的香调家族色一致(柑橘金、玫瑰粉、木质紫等),让柱状图本身就是一份"色彩化的香调地图"。ForEach 键值用 c.perfume(香水名唯一),保证柱子可识别。整页用 Scroll 纵向包裹,下方还有"一瓶香的四段旅程"香调结构图。
3.27 tabLast 香调结构条形图
Text('一瓶香的四段旅程').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(noteList, (n: NoteItem, i: number) => {
Row({ space: 10 }) {
Text((i + 1).toString()).fontSize(10).fontColor(COLORS.white)
.width(20).height(20).borderRadius(10).backgroundColor(n.color)
.textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(n.label).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row()
.height(8).borderRadius(4).backgroundColor(n.color).width(noteW(n.pct))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(n.pct.toString() + '%').fontSize(9).fontColor(n.color).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card2)
}, (n: NoteItem) => n.label)
留香指数页下半部分是"一瓶香的四段旅程"香调结构图,用 ForEach(noteList) 渲染四个阶段(前调/中调/后调/尾迹)。每行是一个 Row:左侧序号圆球(20×20 圆形,背景色取阶段色,白色数字居中)、中间阶段标签 + 进度条(Row 高 8、圆角 4、背景阶段色、宽 noteW(n.pct) 即 pct%)、右侧百分比数值(阶段色粗体)。
进度条用 Row().width(noteW(n.pct)) 实现,noteW 纯函数将百分比数值转为 CSS 宽度字符串(如 noteW(90) 返回 "90%")。前调 90% 最长、尾迹 30% 最短,直观呈现"香气随时间衰减"的曲线。进度条背景色与序号球、百分比文字同色,形成"一阶段一色"的视觉单元。
四行卡片背景用 COLORS.card2 浅粉灰(比上半部分柱状图卡的 COLORS.card 白底略深),形成上下两段的层次区分。整页"柱状图(单品留香)+ 条形图(阶段结构)"双图组合,从"单品横向对比"和"单瓶纵向演变"两个维度展示留香数据,是香品电商"专业内容可视化"的典型设计。
3.28 tabRecipes 配方工坊时间轴
@Builder
tabRecipes() {
Scroll() {
Column({ space: 0 }) {
Text('我的配方手账').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%').padding({ bottom: 10 })
ForEach(this.recipes, (r: RecipeItem, i: number) => {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(r.icon).fontSize(18)
Text(r.drops.toString() + '滴').fontSize(8).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Column().width(2).layoutWeight(1).backgroundColor(COLORS.line).margin({ top: 4 })
}
.width(56)
.constraintSize({ minHeight: 104 })
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(r.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(r.level).fontSize(7).fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(levelColor(r.level))
}
Text(r.theme + ' · 基调 ' + r.base).fontSize(8).fontColor(COLORS.sub)
Text(r.steps.toString() + ' 步 · 作者 ' + r.author).fontSize(8).fontColor(COLORS.blue)
Row({ space: 8 }) {
Button().width(60).height(24).borderRadius(12).backgroundColor(COLORS.card2)
.onClick(() => { this.openDelRecipe(i); })
Text('删除配方').fontSize(8).fontColor(COLORS.red).margin({ left: -60 })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}, (r: RecipeItem) => r.id.toString())
tabRecipes 是"配方工坊"内容页,以时间轴卡片展示用户的配方手账。每张配方卡是一个 Row:左侧 56px 宽的时间轴节点列(emoji 图标 + 滴数 + 一条 2px 宽的纵向连线),右侧是配方详情卡(名称+难度徽章、主题+基调、步骤数+作者、删除按钮)。连线用 Column().width(2).layoutWeight(1).backgroundColor(COLORS.line) 实现,layoutWeight(1) 让它填满剩余高度,形成"节点-连线-节点"的时间轴视觉。
constraintSize({ minHeight: 104 }) 保证时间轴列有最小高度,避免短配方卡连线过短。难度徽章用 levelColor(r.level) 纯函数着色,白色文字 + 圆角小胶囊,是"枚举值→彩色标签"的标准实现。删除按钮同样是"Button + 负边距 Text"叠加写法,文字用 COLORS.red 红色警示。
这里遍历 @State recipes,所以 doDel 执行 splice 后配方卡会即时消失,时间轴自动收缩。Column({ space: 0 }) 让卡片之间无间距,靠卡片自身的内边距和背景色形成视觉分隔,是时间轴"连贯感"的布局选择。整页是"个人创作管理"的形态,与香评榜的"社区内容流"形成对照。
3.29 tabSessions 体验预约门店卡
@Builder
tabSessions() {
Scroll() {
Column({ space: 10 }) {
Text('工坊体验预约').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(this.sessions, (s: SessionItem, i: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('🕯️').fontSize(22)
Column({ space: 2 }) {
Text(s.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('📍 ' + s.place + ' · ' + s.teacher).fontSize(8).fontColor(COLORS.sub)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(s.status === '即将满员' ? '即将满员' : '可预约').fontSize(7)
.fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor(s.status === '即将满员' ? COLORS.gold : COLORS.green)
}.width('100%')
Row({ space: 10 }) {
Text('📅 ' + s.date).fontSize(9).fontColor(COLORS.blue)
Text('⏱ ' + s.hours.toString() + ' 小时').fontSize(9).fontColor(COLORS.sub)
Text('🎫 ' + s.quota).fontSize(9).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text(s.price).fontSize(11).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
}.width('100%')
Row({ space: 8 }) {
Button().width(72).height(26).borderRadius(13).backgroundColor(COLORS.card2)
.onClick(() => { this.openEdit(i); })
Text('改约').fontSize(9).fontColor(COLORS.sub).margin({ left: -60 })
Button().width(72).height(26).borderRadius(13).backgroundColor(COLORS.card2)
.onClick(() => { this.openDelSession(i); })
Text('取消预约').fontSize(9).fontColor(COLORS.red).margin({ left: -72 })
}.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
.border({ width: 1, color: COLORS.line })
}, (s: SessionItem) => s.id.toString())
tabSessions 是"体验预约"内容页,以门店卡列表展示用户的线下预约。每张卡含三行:第一行是蜡烛 emoji + 课程名 + 地点·老师 + 名额状态徽章;第二行是日期·时长·名额·价格信息行;第三行是"改约"和"取消预约"双操作按钮。名额徽章根据 status 是否等于"即将满员"切换金色或绿色背景,是"状态→警示色"的映射。
卡片的边框 border({ width: 1, color: COLORS.line }) 给预约卡加了细线边框,与其他内容页的无边框白底卡形成区分,暗示"预约卡是可操作的事务卡"而非纯展示卡。两个操作按钮都是"Button + 负边距 Text"写法,"改约"文字用 COLORS.sub 灰色(中性操作),"取消预约"用 COLORS.red 红色(破坏性操作),通过颜色传达操作风险等级,是交互设计的细节。
遍历 @State sessions,所以 doEdit 修改标题/日期后卡片即时刷新,doDel 取消预约后卡片消失。openEdit(i) 和 openDelSession(i) 把当前卡片索引传入弹窗状态,弹窗再据此定位操作目标,是"列表项→弹窗→列表项"的完整交互闭环。
3.30 tabCircle 调香师圈动态流
@Builder
tabCircle() {
Scroll() {
Column({ space: 10 }) {
Text('调香师动态').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach([['白露老师', '🕯️', '新到的鸢尾根黄油开罐了,粉感浓郁到爆炸,做"初雪的庭院"后调绝佳,限量 3 份配方位。'],
['小满', '🌾', '亲子课上小朋友调出了"西瓜泡泡糖",意外的好闻,孩子的鼻子果然没有被驯化。'],
['墨水', '🖋️', '"旧书店"配方 v3:把乳香比例降到 12%,纸张感立刻出来了,木质不抢戏。'],
['甜度超标', '🍭', '测试香草醛不同产地差异中:马达加斯加的奶感最圆润,墨西哥的偏木,笔记已更新。'],
['格拉斯学徒', '🕊️', '在格拉斯交换的第 40 天,看了茉莉采摘,凌晨四点开始,一斤花出一克净油,敬意+1。']], (a: string[]) => {
Row({ space: 10 }) {
Text(a[1]).fontSize(28)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(a[0]).fontSize(11).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text('认证调香师').fontSize(7).fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(COLORS.lilac)
}
Text(a[2]).fontSize(10).fontColor(COLORS.title).maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
}, (a: string[]) => a[0])
tabCircle 是"调香师圈"内容页,以"左头像右文"信息流展示五位调香师的动态。数据直接以二维字符串数组 [['白露老师','🕯️','动态文案'], ...] 的形式内联在 ForEach 中,每条是一个 string[]:a[0] 是昵称、a[1] 是头像 emoji、a[2] 是动态文案。这种"内联数据"写法适合内容固定、量小的场景,避免了单独定义接口和常量。
每条动态是一个 Row:左侧大号 emoji 头像(28 号),右侧昵称 + "认证调香师"丁香色徽章 + 动态文本(最多 3 行省略)。"认证调香师"徽章用 COLORS.lilac 丁香紫背景白字,是"身份标识"的视觉化。动态文案都带有强烈的"调香师视角"专业感(“鸢尾根黄油开罐”、“乳香比例降到 12%”、“一斤花出一克净油”),既是内容,也是品牌专业形象的传递。
ForEach 键值用 a[0](昵称),保证每条动态唯一可识别。整页是"社区动态流"形态,与香评榜的"用户评论流"形成"专业 vs 大众"的内容分层——调香师圈是 PGC(专业生成内容),香评榜是 UGC(用户生成内容),两者共同构成香品社区的完整内容生态。
3.31 pageScentLib 香料库主页面
@Builder
pageScentLib() {
Scroll() {
Column({ space: 10 }) {
Text('🧪 香料库').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(scentList, (s: ScentItem) => {
Row({ space: 10 }) {
Column() { Text(s.icon).fontSize(22) }
.width(46).height(46).borderRadius(12).backgroundColor(COLORS.card2)
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Row({ space: 4 }) {
Text(s.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(s.hot).fontSize(9)
}
Text(s.family + ' · ' + s.note + ' · ' + s.origin).fontSize(8).fontColor(COLORS.sub)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column({ space: 2 }) {
Text(s.price).fontSize(10).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text('库存 ' + s.stock.toString()).fontSize(8).fontColor(COLORS.sub)
}.alignItems(HorizontalAlign.End)
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (s: ScentItem) => s.id.toString())
pageScentLib 是底部主 Tab"香料库"对应的主页面,与首页内容 Tab 中的"香料库"不同——这里是列表形态而非宫格。每条香料是一个 Row:左侧 46×46 圆角方块内居中 emoji 图标、中间香料名+热门标记 + 香调·位置·产地信息、右侧价格 + 库存。这种"左图中文右价"的列表卡是电商商品列表的经典布局。
标题字号 15(比内容 Tab 的 13 略大),暗示主页面比内容子页有更高的层级权重。s.hot 火焰 emoji 紧跟香料名,是"热门标记"的轻量呈现。价格用玫瑰色粗体,库存用灰色小字,形成"主信息突出、辅信息弱化"的视觉层次。整页纵向滚动,是"商品浏览列表"的标准体验,适合用户逐条研读香料详情。
这里遍历的是全局只读常量 scentList,所以香料库主页面是纯展示页,不支持增删改——这与"我的配方手账"(@State recipes 可删)和"工坊体验预约"(@State sessions 可改可删)形成"只读 vs 可写"的数据权限分层。
3.32 pageWorkshop 工坊活动主页面
@Builder
pageWorkshop() {
Scroll() {
Column({ space: 10 }) {
Text('🕯️ 工坊活动').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('12').fontSize(18).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text('在售香料').fontSize(8).fontColor(COLORS.sub)
}.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('5').fontSize(18).fontColor(COLORS.lilac).fontWeight(FontWeight.Bold)
Text('体验课程').fontSize(8).fontColor(COLORS.sub)
}.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('386').fontSize(18).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('累计出瓶').fontSize(8).fontColor(COLORS.sub)
}.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}.width('100%')
ForEach(sessionList, (s: SessionItem) => {
Column({ space: 5 }) {
Text(s.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('📅 ' + s.date + ' · ' + s.hours.toString() + '小时 · ' + s.quota).fontSize(8).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('👩🏫 ' + s.teacher).fontSize(8).fontColor(COLORS.blue)
Column().layoutWeight(1)
Text(s.price).fontSize(11).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
}.width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
}, (s: SessionItem) => s.id.toString())
pageWorkshop 是底部主 Tab"工坊"对应的主页面,包含工坊数据概览和活动列表。顶部是三个等宽统计卡:在售香料 12(玫瑰色)、体验课程 5(丁香色)、累计出瓶 386(蓝色),每个数字 18 号粗体,下方 8 号灰色说明。三个数字分别用三种品牌色,呼应应用配色体系。这种"三数据卡"是运营概览页的标配,让用户一眼了解工坊规模。
下方是 ForEach(sessionList) 遍历全局预约常量,渲染活动列表。每条活动卡含课程名、日期·时长·名额信息行、老师·价格行。与"体验预约"内容页(tabSessions,遍历 @State sessions,可改可删)不同,这里遍历的是全局只读 sessionList,是"活动目录"的展示形态,不带操作按钮——用户在这里浏览工坊提供的所有活动,到"体验预约"内容页才进行个人预约的管理。
这种"同一数据在只读页和可写页分别呈现"的设计,体现了"目录 vs 我的"的常见产品分层:工坊主页面是"全部活动目录"(P 侧),体验预约内容页是"我的预约"(C 侧)。两者共享数据源(sessionList 作为 @State sessions 的初始模板),但呈现形态和交互能力不同。
3.33 pageMine 我的个人主页

@Builder
pageMine() {
Scroll() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🫧').fontSize(40)
Column({ space: 3 }) {
Text('新鼻子 David').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('闻香笔记 28 篇 · 调香等级 Lv.4').fontSize(9).fontColor(COLORS.sub)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').padding(16).borderRadius(16)
.linearGradient({ angle: 100, colors: [['#F4EBF0', 0], ['#FFFFFF', 1]] })
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('6').fontSize(18).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text('我的配方').fontSize(8).fontColor(COLORS.sub)
}.layoutWeight(1)...
Column({ space: 2 }) {
Text('28').fontSize(18).fontColor(COLORS.lilac).fontWeight(FontWeight.Bold)
Text('香评笔记').fontSize(8).fontColor(COLORS.sub)
}.layoutWeight(1)...
Column({ space: 2 }) {
Text('156').fontSize(18).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('获赞').fontSize(8).fontColor(COLORS.sub)
}.layoutWeight(1)...
}.width('100%')
Text('闻香徽章').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
Column({ space: 3 }) { Text('👃').fontSize(22); Text('百香初闻').fontSize(8).fontColor(COLORS.sub) }...
Column({ space: 3 }) { Text('🧪').fontSize(22); Text('第一瓶').fontSize(8).fontColor(COLORS.sub) }...
Column({ space: 3 }) { Text('🌹').fontSize(22); Text('花香控').fontSize(8).fontColor(COLORS.sub) }...
}.width('100%')
Text('我的香柜').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(['🌿 雨后苔原 No.7 · 剩 32ml', '🌹 玫瑰低语 · 剩 45ml', '🍵 月下白茶 · 剩 8ml · 待补'], (s: string) => {
Text(s).fontSize(10).fontColor(COLORS.title)
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (s: string) => s)
pageMine 是底部主 Tab"我的"对应的个人主页,包含用户信息卡、数据统计、闻香徽章、我的香柜四部分。用户信息卡使用 linearGradient 从 #F4EBF0(浅粉灰)到 #FFFFFF(白)的 100 度渐变,40 号泡泡 emoji 头像 + 昵称 + 等级说明。这是全应用第二处使用渐变(第一处是头部今日主打卡的玫瑰-丁香渐变),两处渐变色板风格统一,强化品牌氛围。
数据统计三卡(我的配方 6、香评笔记 28、获赞 156)与工坊主页面的三数据卡结构一致,但数值是个人数据,颜色也用玫瑰/丁香/蓝三色,形成跨页面的视觉一致性。闻香徽章三卡(百香初闻、第一瓶、花香控)是"成就系统"的雏形,用 emoji 表达徽章图标。我的香柜是一个 ForEach 遍历字符串数组,渲染已购香水的库存清单(含剩余毫升数),其中"月下白茶 · 剩 8ml · 待补"提示补货,是"库存预警"的轻量实现。
整页是"个人中心"的标准形态,整合了身份、数据、成就、资产四类信息,是用户在应用中的"数字身份卡"。
3.34 modalOverlay 通用遮罩
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
modalOverlay 是三套弹窗共用的遮罩层 Builder,它接收一个 onClose 回调函数作为参数。遮罩本身是一个全屏 Column,背景色 COLORS.mask(#000000CC,80% 不透明黑色),点击时执行 onClose() 关闭弹窗。这种"遮罩点击即关闭"是弹窗交互的标配,让用户能通过点击空白区域快速退出弹窗,而不必寻找关闭按钮。
将遮罩抽离为带参数的 @Builder 是重要的复用设计。三套弹窗(新增/编辑/删除)都需要遮罩,如果每个弹窗都内联一份遮罩代码,会有三处重复。通过 modalOverlay(() => { this.xxxModal = false; }) 的调用方式,每个弹窗传入自己的关闭函数,既复用了遮罩视觉,又允许每个弹窗有独立的关闭逻辑。这是 @Builder 带参复用的典型场景。
COLORS.mask 的 80% 不透明度是经过权衡的:完全透明会让用户误以为遮罩区可点击;完全不透明会过度遮挡背景;80% 既能让用户感知到"背景被压暗、弹窗在前台",又能隐约看到底层内容,维持空间上下文。这种半透明遮罩是移动端弹窗的视觉惯例。
3.35 addModalBody 新增香评弹窗
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发表香评').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '昵称(可留空)', text: this.addNick })
.fontSize(11).height(38).borderRadius(10)
.backgroundColor(COLORS.card2).fontColor(COLORS.title)
.onChange((v: string) => { this.addNick = v; })
TextInput({ placeholder: '今天闻到了什么?写下你的感受…', text: this.addText })
.fontSize(11).height(60).borderRadius(10)
.backgroundColor(COLORS.card2).fontColor(COLORS.title)
.onChange((v: string) => { this.addText = v; })
Row({ space: 10 }) {
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
.onClick(() => { this.addModal = false; })
Text('取消').fontSize(11).fontColor(COLORS.sub).margin({ left: -66 })
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.rose)
.onClick(() => { this.doAdd(); })
Text('发布').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -66 })
}.width('100%')
}
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
addModalBody 是"新增香评"弹窗体,含标题、昵称输入框、评论文本框、取消/发布双按钮。两个 TextInput 都使用受控模式:text: this.addNick 绑定状态值,onChange 回调将输入值同步回状态。这种"状态即输入源"的受控写法保证了弹窗内输入与组件状态的一致,doAdd 读取 this.addText 时能拿到最新输入。
评论输入框 height(60) 比昵称框(38)高,暗示"多行文本"。两个按钮用"Button + 负边距 Text"写法,"取消"灰色次按钮 + "发布"玫瑰色主按钮,是"次操作 + 主操作"的标准双按钮组合。弹窗体宽 86%,内边距 18,圆角 16,白底——这些尺寸是移动端弹窗的通行规格,保证弹窗在屏幕中居中且留有边距。
弹窗的 addNick/addText 在 openAdd 时被清空,所以每次打开都是干净输入框,避免残留上次输入。doAdd 在 addText 非空时才执行添加并关闭,空文本时弹窗保持打开(不关闭也不报错),是"静默校验"的体验选择——不弹错误提示,只是不响应,让用户自然补全内容。
3.36 editModalBody 编辑预约弹窗
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('调整体验预约').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '课程名称', text: this.editTitle })
.fontSize(11).height(38).borderRadius(10)
.backgroundColor(COLORS.card2).fontColor(COLORS.title)
.onChange((v: string) => { this.editTitle = v; })
TextInput({ placeholder: '日期 如 2026-09-12', text: this.editDate })
.fontSize(11).height(38).borderRadius(10)
.backgroundColor(COLORS.card2).fontColor(COLORS.title)
.onChange((v: string) => { this.editDate = v; })
Text('提示:signature 定制课改约需提前 48 小时').fontSize(8).fontColor(COLORS.sub).width('100%')
Row({ space: 10 }) {
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
.onClick(() => { this.editModal = false; })
Text('取消').fontSize(11).fontColor(COLORS.sub).margin({ left: -66 })
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.lilac)
.onClick(() => { this.doEdit(); })
Text('保存').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -66 })
}.width('100%')
}
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
editModalBody 是"编辑体验预约"弹窗体,结构与新增弹窗类似,但有几点不同。第一,输入框是课程名称和日期(而非昵称和评论),对应 editTitle/editDate 状态。第二,openEdit(idx) 在打开弹窗时会把目标预约的当前 title 和 date 预填到输入框(this.editTitle = this.sessions[idx].title),所以编辑弹窗是"带预填"的,用户看到的是当前值,修改后保存——这是"编辑"与"新增"的核心交互差异。
第三,弹窗底部多了一行提示文案"提示:signature 定制课改约需提前 48 小时",用 8 号灰色小字,是"业务规则提示"的轻量呈现,提醒用户高价值课程的改约规则。第四,主按钮用 COLORS.lilac 丁香紫(而非新增弹窗的玫瑰红),"保存"文字白色粗体——这种"新增用玫瑰、编辑用丁香"的色彩区分,让用户能从弹窗主按钮颜色快速判断当前是"新增"还是"编辑"状态,是色彩语义化的细节。
doEdit 做了索引越界保护(editIdx >= 0 && editIdx < length),将 editTitle/editDate 赋值回 sessions[editIdx],由于 SessionItem 是 @Observed,对应卡片的标题和日期会即时刷新。整窗宽度、内边距、圆角与新增弹窗完全一致,保证三套弹窗的视觉规格统一。
3.37 delModalBody 删除确认弹窗
@Builder
delModalBody() {
Column({ space: 12 }) {
Text('确认操作').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(this.delTarget === 'session' ? '取消预约后名额将立即释放,定金 3 个工作日内退回。' : '删除配方后手账记录将无法恢复,确认继续吗?')
.fontSize(10).fontColor(COLORS.sub).textAlign(TextAlign.Center)
Row({ space: 10 }) {
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
.onClick(() => { this.delModal = false; })
Text('再想想').fontSize(11).fontColor(COLORS.sub).margin({ left: -82 })
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.red)
.onClick(() => { this.doDel(); })
Text('确认删除').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -94 })
}.width('100%')
}
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
delModalBody 是"删除确认"弹窗体,是三套弹窗中最简洁的——只有标题、确认文案、双按钮,无输入框。其精妙之处在于通过 delTarget 字段复用:当 delTarget === 'session' 时显示"取消预约后名额将立即释放,定金 3 个工作日内退回。“,否则显示"删除配方后手账记录将无法恢复,确认继续吗?”。一个弹窗服务"取消预约"和"删除配方"两种场景,文案随场景切换。
主按钮用 COLORS.red 红色(新增玫瑰、编辑丁香、删除红色),形成三套弹窗"主操作色 = 玫瑰/丁香/红"的色彩语义梯度——红色最警示,用于不可逆的删除操作。取消按钮文字是"再想想"而非"取消",用更口语化的措辞降低删除操作的严肃感,是"温和劝退"的文案策略。doDel 根据 delTarget 分流到 sessions.splice 或 recipes.splice,一套弹窗驱动两种删除。
确认文案 textAlign(TextAlign.Center) 居中对齐,因为删除弹窗是"警示型"弹窗,居中文案更聚焦。整窗结构与新增、编辑弹窗一致(86% 宽、内边距 18、圆角 16、白底),保证三套弹窗的视觉规格统一。这套"三弹窗 + 三主色 + 一遮罩"的弹窗体系,是应用交互层的核心实现。
3.38 bottomBar 底部主导航
@Builder
bottomBar() {
Row({ space: 0 }) {
ForEach(NAV_LIST, (ni: NavItem, i: number) => {
Column({ space: 3 }) {
Text(ni.icon).fontSize(18)
Text(ni.label)
.fontSize(9)
.fontColor(this.mainTab === i ? COLORS.rose : COLORS.sub)
.fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.mainTab = i; this.subTab = 0; })
}, (ni: NavItem) => ni.label)
}
.width('100%')
.backgroundColor(COLORS.card)
.border({ width: 1, color: COLORS.line })
}
bottomBar 是底部主 Tab 导航,通过 ForEach(NAV_LIST) 遍历四个导航项生成。每个 Tab 是一个 Column:emoji 图标(18 号)+ 文字标签(9 号)。选中态(mainTab === i)文字变玫瑰色粗体,未选中灰色常规——这是 Tab 选中态的标准视觉编码。layoutWeight(1) 让四个 Tab 等宽,padding({ top: 6, bottom: 6 }) 保证点击区域足够大(符合移动端 44pt 最小点击区域的近似)。
onClick 同时更新 mainTab = i 和 subTab = 0。前者切换主页面,后者重置内容 Tab 到第一个(精选)——这保证了用户从其他主 Tab 切回首页时,内容 Tab 不会停留在上次离开的子页(如"调香师圈"),而是回到"精选",这是"首页即门面"的产品逻辑。底部栏顶部有 1px 细线边框(COLORS.line),与内容区形成视觉分隔。
backgroundColor(COLORS.card) 白底,保证底部栏在任何内容背景下都清晰可读。整条底部栏是应用的"全局导航锚点",无论用户在哪个主 Tab,都能一键切换到其他主功能,是移动端"底部 Tab 模式"的标准实现。
3.39 build 主构建:Stack 叠加架构
build() {
Stack() {
Column() {
this.header()
if (this.mainTab === 0) {
Column() {
this.subNav()
if (this.subTab === 0) { this.tabPick() }
else if (this.subTab === 1) { this.tabScents() }
else if (this.subTab === 2) { this.tabReviews() }
else if (this.subTab === 3) { this.tabLast() }
else if (this.subTab === 4) { this.tabRecipes() }
else if (this.subTab === 5) { this.tabSessions() }
else { this.tabCircle() }
}
.layoutWeight(1)
.width('100%')
.alignItems(HorizontalAlign.Center)
.backgroundColor(COLORS.bg)
} else if (this.mainTab === 1) { this.pageScentLib() }
else if (this.mainTab === 2) { this.pageWorkshop() }
else { this.pageMine() }
this.bottomBar()
}
.width('100%')
.height('100%')
build 是应用的根构建方法,用 Stack 作为最外层容器,内部叠加三层。第一层是主内容 Column:顶部 header()、中间主内容区(根据 mainTab 切换)、底部 bottomBar()。中间主内容区用 if-else if-else 链根据 mainTab 渲染:mainTab === 0 时是首页(含 subNav() + 七个内容子 Tab 的 if-else 链),mainTab === 1/2/3 时分别是香料库、工坊、我的主页面。
首页内容区的 if-else 链用 subTab 切换七个内容子页:subTab === 0 精选、=== 1 香料库、=== 2 香评榜、=== 3 留香指数、=== 4 配方工坊、=== 5 体验预约、else 调香师圈。这种"主 Tab + 子 Tab 双层 if-else"的结构,让一个 build 方法能承载 4 个主页面 + 7 个子页面共 11 个视图状态,是单页面多状态路由的典型实现。
首页内容区 layoutWeight(1) 让它占据 header 和 bottomBar 之间的所有剩余空间,alignItems(HorizontalAlign.Center) 让内容居中。整层 backgroundColor(COLORS.bg) 保证内容区背景统一。width('100%').height('100%') 让主内容层填满整个 Stack。
3.40 build 特效层与弹窗层叠加
// 特效层仅覆盖内容区
Column() {
this.fxLayer()
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End)
.hitTestBehavior(HitTestMode.None)
// 弹框层
if (this.addModal) {
Column() {
this.modalOverlay(() => { this.addModal = false; })
Column() {
this.addModalBody()
}
.constraintSize({ maxHeight: '80%' })
.zIndex(999)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
if (this.editModal) { ... }
if (this.delModal) { ... }
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
Stack 的第二层是特效层:一个全屏 Column 包裹 fxLayer(),justifyContent(FlexAlign.End) 让特效内容靠下(对应"仅覆盖内容区不遮挡头部"的约束),hitTestBehavior(HitTestMode.None) 让这一层不拦截任何触摸事件,点击穿透到下层内容——这是"视觉叠加层不影响交互"的关键配置。
第三层是弹窗层:三套弹窗各自用 if (this.xxxModal) 条件渲染。每个弹窗是一个全屏 Column,内含 modalOverlay(遮罩)和 xxxModalBody(弹窗体)。弹窗体用 constraintSize({ maxHeight: '80%'/'70%'/'60%' }) 限制最大高度(新增 80%、编辑 70%、删除 60%,按内容多寡递减),zIndex(999) 保证弹窗在最顶层。justifyContent(FlexAlign.Center) 让弹窗体在遮罩中居中。
三套弹窗的 if 条件互斥(openXxx 方法保证同时只有一个为 true),所以任何时刻最多一个弹窗显示。Stack 的叠加顺序是"主内容 → 特效 → 弹窗",后渲染的在上层,符合视觉层级。整个 build 用一个 Stack + 三层 if 条件,就把静态内容、动态特效、模态弹窗三大视觉层优雅地组合在一起,是"Stack 叠加架构"的教科书式实现。
四、完整流程图
上图展示了应用从启动到销毁的完整数据与控制流。aboutToAppear 启动定时器驱动特效层持续脉动,build 根据 mainTab 和 subTab 双层状态渲染对应内容页。用户交互(点击 Tab、打开弹窗、执行增删改)通过更新 @State 变量触发 build 重新执行,配合 @Observed 数据模型实现局部刷新。组件销毁时 aboutToDisappear 清理定时器,闭环完整。
五、技术对比表
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 颜色管理 | interface 契约 + const 实例 + 语义字段名 | 全站单源配色,字段名贴合香品词汇 | 编译期类型检查,无运行时色值解析开销 |
| 状态管理 | @State 组件内状态 + @Observed 数据模型类 | 双层观察:数组引用 + 元素属性 | 精准局部刷新,避免全页重建 |
| 导航系统 | mainTab + subTab 双数字索引 + if-else 链 | 单页面多状态路由,无路由框架 | 条件渲染仅渲染当前页,未渲染页零开销 |
| 数据建模 | @Observed class + 构造函数 + 默认值 | 展示优先,price/time 存格式化字符串 | 减少视图层格式化计算 |
| 列表渲染 | ForEach + keyGenerator + if 条件过滤 | 键值唯一保证复用,if 实现截断/分行 | 长列表差分更新,避免全量重建 |
| 弹窗系统 | 三套 @Builder + 互斥布尔状态 + zIndex | 一遮罩复用 + 三主色区分场景 | 条件渲染,未显示弹窗零开销 |
| 特效系统 | setInterval 驱动 tick/bloom + 纯函数坐标 | 确定性伪随机,慢节拍贴合香意 | 700ms 低频更新,hitTestMode 穿透 |
| 图表系统 | 基础组件 Column/Row + 纯函数映射尺寸 | 无图表库依赖,布局组件模拟图表 | 轻量渲染,无第三方库开销 |
| 按钮实现 | Button + Text 负边距叠加 | 绕开默认样式,自由控制文字 | 增加一层 Text,视觉灵活性优先 |
| 布局策略 | layoutWeight 弹性 + 百分比 + vp 混合单位 | 多设备弹性适配,圆角内边距而非像素 | 弹性布局自动适配屏幕尺寸 |
| 渐变背景 | linearGradient + 品牌色两端 | 头部主卡 + 我的卡片两处渐变 | 渐变由 GPU 渲染,性能良好 |
| 生命周期 | aboutToAppear 启动定时器 + aboutToDisappear 清理 | 成对管理副作用 | 定时器及时清理,防内存泄漏 |
| 纯函数工具 | 顶层 function,无副作用 | 值→视觉属性映射可独立测试 | 无状态,调用无副作用 |
| 模数据与副本 | 全局 const 模板 + @State 组件内副本 | 初始数据统一来源,修改不污染全局 | 副本独立观察,互不影响 |
| 文本处理 | length 判断 + slice 截断 + maxLines + Ellipsis | 前端截断防溢出,组件级省略 | 截断在渲染前完成,避免布局抖动 |
| emoji 图标 | string 字段存 emoji,Text 渲染 | 无图片资源,全应用统一图标体系 | 零资源加载,emoji 由系统字体渲染 |
| 边框分隔 | border 1px line 色 + backgroundColor 层次 | 细线分隔 + 背景色层次双重区分 | 1px 边框 GPU 友好 |
| 遮罩层 | 全屏 Column + 半透明黑 + onClick 关闭 | 80% 不透明,点击穿透关闭 | 单层渲染,无模糊滤镜开销 |
六、详细总结
6.1 架构设计:单页面多 Builder 的分层组织
本应用采用"单 @Entry 组件 + 十余个 @Builder 方法"的组织架构,将一个功能丰富的香品电商首页浓缩在一个结构体内。这种架构的优势在于"高内聚低通信":所有 @Builder 共享组件的 @State,无需跨组件传递数据,避免了 @Prop/@Link 的双向同步成本和 @Provide/@Consume 的跨层级传递复杂度。每个 @Builder 职责单一——header 管头部、tabPick 管精选、addModalBody 管新增弹窗——阅读时按方法名即可定位功能模块。build 方法作为"总装配线",用 Stack 三层叠加把所有 Builder 组装成完整页面,结构清晰如蓝图。这种模式在中小型应用中是"恰到好处"的架构选择,既避免了一个 build 方法过长难以维护,又避免了过度拆分独立组件带来的通信成本。当应用规模进一步扩大时,可以将各 @Builder 拆分为独立 @Component,通过 @Prop/@Link 接入状态,实现渐进式重构。
6.2 状态管理:双层观察与数据副本策略
应用的状态管理体现了 ArkUI 的精髓。@State 装饰了 13 个组件内状态变量,覆盖导航、特效、弹窗、列表四大职责域;@Observed 装饰了四个数据模型类,使元素属性赋值也能触发局部刷新。两者协同,形成了"数组级 + 属性级"的双层观察体系。在数据策略上,应用采用"全局 const 模板 + @State 组件内副本"的分层:全局 scentList/reviewList/recipeList/sessionList 作为只读模板,@State sessions/recipes/reviews 作为可变副本,初始化时从模板复制。这种分层使初始数据有统一来源(未来可替换为接口拉取),组件内修改不污染全局,且副本独立观察、互不影响。弹窗状态采用"三布尔互斥 + 一索引定位 + 一目标分流"的紧凑设计,用最少的状态变量承载了三套弹窗的完整交互逻辑。
6.3 性能优化:精准观察与轻量特效
性能优化贯穿应用多个层面。在状态标注上,只有"变化需驱动 UI"的变量才用 @State,定时器 ID fxTimer 用普通成员变量,避免不必要的观察开销。在列表渲染上,每个 ForEach 都提供 keyGenerator,保证列表项可复用,差分更新而非全量重建。在特效层,setInterval 选择 700ms 低频节拍,既贴合"香气缓慢扩散"的意象,又大幅降低帧刷新压力;hitTestBehavior(HitTestMode.None) 让特效层不拦截触摸事件,保证下层交互不受影响。在图表实现上,柱状图和条形图完全用基础布局组件(Column/Row + 尺寸属性)拼装,无任何图表库依赖,渲染开销极低。在弹窗系统上,三套弹窗用 if 条件渲染,未显示时零开销。这些优化共同保证了应用在功能丰富的同时保持流畅响应。
6.4 交互设计:色彩语义与温和劝退
交互设计体现了对用户体验的精细打磨。色彩语义化是核心:底部 Tab 选中用玫瑰色,内容 Tab 第一排选中玫瑰、第二排选中丁香,弹窗主按钮"新增玫瑰/编辑丁香/删除红"形成警示梯度,让用户能从颜色快速判断当前状态与操作风险。文案设计注重"温和劝退":删除确认弹窗的取消按钮叫"再想想"而非"取消",用口语化措辞降低破坏性操作的严肃感;新增香评时文本为空不报错只是不响应,让用户自然补全。编辑弹窗"带预填",用户看到当前值再修改,符合"编辑"的心智模型。遮罩点击即关闭,提供快速退出路径。这些细节共同构建了"专业但不冷漠、引导但不强迫"的交互气质,契合香品电商"内容即服务"的调性。
6.5 可扩展性:契约化配色与数据驱动 UI
应用在多处预留了扩展空间。配色体系以 interface ColorPalette 为契约,全站通过 COLORS.xxx 引用,未来推出夜间版或节日主题,只需替换一个符合接口的对象实例即可全站换肤,零改动视图代码。底部导航和内容 Tab 的数据抽离为 NAV_LIST 常量数组和 ForEach 遍历,增加 Tab 只需加数据项。数据模型类用 @Observed class + 构造函数,字段类型明确,未来接入真实接口时,只需把 @State 数组的初始化从"字面量"改为"接口回调赋值",模型结构不变。纯函数工具集(scoreColor/levelColor/lastBarH 等)将"值→视觉"映射独立化,调整阈值或尺寸只需改函数,不影响视图。这种"契约化配色 + 数据驱动 UI + 纯函数映射"的三重解耦,使应用具备良好的可维护性与可演进性——每一层的变化都有清晰的边界,修改一层不会波及其他层,是工程化前端应用的理想姿态。
6.6 行业适配:氛围层与专业内容的协同
本应用对香品电商行业的适配体现在"氛围层"与"专业内容层"的协同设计上。氛围层是"看不见的香气"的可视化:头部粉紫渐变今日主打卡、香气粒子扩散特效、玫瑰/丁香/琥珀/麝香等以香调命名的配色字段、emoji 图标体系(玫瑰/檀木/柑橘/海盐等),共同构建出柔和温润、带复古调性的视觉氛围,让用户在浏览商品前先进入"香气的心境"。专业内容层则是"看得见的信任":12 种香料带真实产地与香调分类、7 款香水留香实测柱状图、4 阶段香调结构衰减图、5 位调香师的专业动态、6 条配方手账、5 个线下体验课程——这些数据不是装饰,而是帮助用户做购买决策的专业依据。氛围层吸引用户停留,专业层促成交易转化,两者缺一不可。这种"氛围与专业并重"的设计哲学,对其他垂类电商(如茶叶、咖啡、精油)也有借鉴价值——任何"感官主导"的商品品类,都需要用视觉氛围弥补线上无法试嗅/试闻的体验缺口。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// 头部样式:香品电商头部(搜索栏 + 定位 + 今日主打香大卡 + 热门香料横滚)无动画
// 底部主tab:首页 / 香料库 / 工坊 / 我的(4个)
// 首页内容tab:精选 / 香料库 / 香评榜 / 留香指数 / 配方工坊 / 体验预约 / 调香师圈(7个,两排 4+3)
// 特效:香气粒子扩散(setInterval 驱动,仅覆盖内容区不遮挡头部)
// 弹框:新增香评 / 编辑体验预约 / 删除配方确认(if条件渲染 + constraintSize + zIndex)
interface ColorPalette {
bg: string;
card: string;
card2: string;
title: string;
sub: string;
line: string;
rose: string;
lilac: string;
cream: string;
amber: string;
musk: string;
gold: string;
red: string;
green: string;
blue: string;
mask: string;
white: string;
}
const COLORS: ColorPalette = {
bg: '#FBF6F8',
card: '#FFFFFF',
card2: '#F4EBF0',
title: '#4A3542',
sub: '#9A8492',
line: '#EDE0E8',
rose: '#C4708F',
lilac: '#9B7FBF',
cream: '#E8D5B8',
amber: '#C89A5B',
musk: '#8A6E82',
gold: '#C9A227',
red: '#C9564F',
green: '#6FA07E',
blue: '#6C8FB5',
mask: '#000000CC',
white: '#FFFFFF'
};
// ============ 香料 ============
@Observed
export class ScentItem {
id: number = 0;
name: string = '';
family: string = '';
note: string = '';
price: string = '';
stock: number = 0;
origin: string = '';
hot: string = '';
icon: string = '';
constructor(id: number, name: string, family: string, note: string, price: string, stock: number, origin: string, hot: string, icon: string) {
this.id = id; this.name = name; this.family = family; this.note = note;
this.price = price; this.stock = stock; this.origin = origin; this.hot = hot; this.icon = icon;
}
}
// ============ 香评 ============
@Observed
export class ReviewItem {
id: number = 0;
nick: string = '';
avatar: string = '';
perfume: string = '';
score: number = 0;
text: string = '';
likes: number = 0;
time: string = '';
constructor(id: number, nick: string, avatar: string, perfume: string, score: number, text: string, likes: number, time: string) {
this.id = id; this.nick = nick; this.avatar = avatar; this.perfume = perfume;
this.score = score; this.text = text; this.likes = likes; this.time = time;
}
}
// ============ 配方 ============
@Observed
export class RecipeItem {
id: number = 0;
name: string = '';
theme: string = '';
drops: number = 0;
base: string = '';
steps: number = 0;
author: string = '';
level: string = '';
icon: string = '';
constructor(id: number, name: string, theme: string, drops: number, base: string, steps: number, author: string, level: string, icon: string) {
this.id = id; this.name = name; this.theme = theme; this.drops = drops;
this.base = base; this.steps = steps; this.author = author; this.level = level; this.icon = icon;
}
}
// ============ 体验预约 ============
@Observed
export class SessionItem {
id: number = 0;
title: string = '';
date: string = '';
hours: number = 0;
price: string = '';
quota: string = '';
teacher: string = '';
place: string = '';
status: string = '';
constructor(id: number, title: string, date: string, hours: number, price: string, quota: string, teacher: string, place: string, status: string) {
this.id = id; this.title = title; this.date = date; this.hours = hours;
this.price = price; this.quota = quota; this.teacher = teacher; this.place = place; this.status = status;
}
}
// ============ 图表 ============
interface LastChartItem {
perfume: string;
val: number;
color: string;
}
const lastCharts: LastChartItem[] = [
{ perfume: '晨雾柑橘', val: 4, color: '#C9A227' },
{ perfume: '玫瑰低语', val: 7, color: '#C4708F' },
{ perfume: '白茶时光', val: 6, color: '#9B7FBF' },
{ perfume: '乌木沉香', val: 10, color: '#8A6E82' },
{ perfume: '铃兰晨露', val: 5, color: '#6FA07E' },
{ perfume: '海盐鼠尾', val: 8, color: '#6C8FB5' },
{ perfume: '檀香奶昔', val: 9, color: '#C89A5B' }
];
interface NoteItem {
label: string;
pct: number;
color: string;
}
const noteList: NoteItem[] = [
{ label: '前调 · 柑橘/青草(15分钟)', pct: 90, color: '#C9A227' },
{ label: '中调 · 花香/果香(2小时)', pct: 72, color: '#C4708F' },
{ label: '后调 · 木质/麝香(8小时)', pct: 55, color: '#8A6E82' },
{ label: '尾迹 · 琥珀/香草(24小时)', pct: 30, color: '#C89A5B' }
];
// ============ 全局写死数据 ============
const scentList: ScentItem[] = [
new ScentItem(1, '大马士革玫瑰精油', '花香调', '中调', '¥398/5ml', 18, '保加利亚', '🔥', '🌹'),
new ScentItem(2, '佛手柑冷压油', '柑橘调', '前调', '¥128/10ml', 42, '意大利', '🔥', '🍊'),
new ScentItem(3, '檀香木精油', '木质调', '后调', '¥458/5ml', 9, '印度迈索尔', '🔥', '🪵'),
new ScentItem(4, '铃兰净油', '花香调', '中调', '¥688/2ml', 4, '法国格拉斯', '', '🪻'),
new ScentItem(5, '海盐 Accord', '海洋调', '前调', '¥88/10ml', 55, '实验室合成', '', '🧂'),
new ScentItem(6, '白麝香', '麝香调', '后调', '¥158/10ml', 31, '实验室合成', '🔥', '🤍'),
new ScentItem(7, '香草醛', '美食调', '尾迹', '¥98/10g', 48, '马达加斯加', '', '🌿'),
new ScentItem(8, '鸢尾根黄油', '粉感调', '中调', '¥928/2ml', 3, '意大利', '', '🪷'),
new ScentItem(9, '广藿香精油', '木质调', '后调', '¥138/10ml', 27, '印尼', '', '🍂'),
new ScentItem(10, '葡萄柚冷压油', '柑橘调', '前调', '¥68/10ml', 63, '美国佛州', '', '🍈'),
new ScentItem(11, '乳香精油', '树脂调', '后调', '¥218/5ml', 15, '阿曼', '', '🕯️'),
new ScentItem(12, '茉莉净油', '花香调', '中调', '¥568/2ml', 6, '埃及', '', '🤍')
];
const reviewList: ReviewItem[] = [
new ReviewItem(1, '雾里看花', '🌸', '玫瑰低语', 9.2, '开场是带露水的青玫瑰,半小时后粉感收拢,像捧着一束还没拆封的花。', 320, '2小时前'),
new ReviewItem(2, '木星来客', '🪐', '乌木沉香', 9.6, '乌木+沉香+一丝烟熏,气场全开,冬天大衣上能留两天,回头率超高。', 508, '5小时前'),
new ReviewItem(3, '清晨六点', '⏰', '晨雾柑橘', 8.5, '通勤安全牌,柑橘青草干净利落,缺点是三小时就散,适合补香勤快的人。', 187, '8小时前'),
new ReviewItem(4, '海边的猫', '🐈', '海盐鼠尾', 9.0, '咸咸的海风混着鼠尾草,像在礁石上晒太阳,中性的恰到好处。', 276, '11小时前'),
new ReviewItem(5, '甜度超标', '🍰', '檀香奶昔', 8.8, '檀香打底上面铺了一层奶,甜而不腻,像温热的杏仁奶,睡前安利。', 233, '昨天'),
new ReviewItem(6, '白衬衫控', '👔', '白麝香 12%', 9.4, '干净的皂感麝香,就是"刚洗完澡"的味道,叠涂什么都不打架。', 401, '昨天'),
new ReviewItem(7, '格拉斯学徒', '🕊️', '铃兰晨露', 8.2, '铃兰还原度很高,就是留香短得像一场春梦,且价格劝退。', 156, '2天前'),
new ReviewItem(8, '调香台前', '🧪', '白茶时光', 9.1, '白茶+鸢尾的粉木感,安静克制,办公室涂它没人会讨厌你。', 298, '2天前')
];
const recipeList: RecipeItem[] = [
new RecipeItem(1, '初雪的庭院', '冬日花木', 42, '檀香+鸢尾', 6, '白露老师', '进阶', '❄️'),
new RecipeItem(2, '六月果园', '夏日果香', 30, '葡萄柚+香草', 4, '小满', '入门', '🍑'),
new RecipeItem(3, '雨后苔原', '湿润绿调', 55, '广藿香+海盐', 8, '白露老师', '高级', '🌿'),
new RecipeItem(4, '旧书店', '纸张木质', 38, '乳香+麝香', 5, '墨水', '进阶', '📚'),
new RecipeItem(5, '焦糖布丁', '美食甜调', 26, '香草+檀香', 3, '甜度超标', '入门', '🍮'),
new RecipeItem(6, '月下白茶', '清透茶香', 34, '佛手柑+白茶', 5, '墨水', '入门', '🍵')
];
const sessionList: SessionItem[] = [
new SessionItem(1, '调香入门体验 90 分钟', '2026-09-12', 2, '¥328', '余 6/10', '白露老师', '工坊一层·调香台 A', '可预约'),
new SessionItem(2, '专属 signature 香定制', '2026-09-18', 4, '¥1280', '余 2/4', '白露老师', '工坊二层·私享室', '即将满员'),
new SessionItem(3, '亲子香气启蒙课', '2026-09-21', 2, '¥258', '余 12/15', '小满', '工坊一层·亲子区', '可预约'),
new SessionItem(4, '木质调专题研习', '2026-10-05', 3, '¥688', '余 5/8', '墨水', '工坊二层·研习室', '可预约'),
new SessionItem(5, '情侣对香手作', '2026-10-18', 3, '¥928', '余 4/6', '白露老师', '工坊二层·私享室', '可预约')
];
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🌸', label: '首页' },
{ icon: '🧪', label: '香料库' },
{ icon: '🕯️', label: '工坊' },
{ icon: '👤', label: '我的' }
];
// ============ 顶层纯函数 ============
function lastBarH(v: number): string {
return (v * 16).toString() + 'vp';
}
function scoreColor(s: number): string {
if (s >= 9.3) { return COLORS.rose; }
if (s >= 8.8) { return COLORS.lilac; }
return COLORS.blue;
}
function levelColor(lv: string): string {
if (lv === '高级') { return COLORS.musk; }
if (lv === '进阶') { return COLORS.rose; }
return COLORS.green;
}
function scentX(seed: number, tick: number): number {
return 20 + ((seed * 47 + tick * 3) % 80);
}
function scentY(seed: number, tick: number): number {
return 10 + ((seed * 29 + tick * 2) % 84);
}
function scentR(tick: number): number {
return 3 + (tick % 4);
}
function noteW(v: number): string {
return v.toString() + '%';
}
@Entry
@Component
struct Page {
@State mainTab: number = 0;
@State subTab: number = 0;
@State tick: number = 0;
@State bloom: boolean = false;
// 弹框状态
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = -1;
@State delTarget: string = 'recipe';
@State editTitle: string = '';
@State editDate: string = '';
@State addNick: string = '';
@State addText: string = '';
// 列表
@State sessions: SessionItem[] = [
new SessionItem(1, '调香入门体验 90 分钟', '2026-09-12', 2, '¥328', '余 6/10', '白露老师', '工坊一层·调香台 A', '可预约'),
new SessionItem(2, '专属 signature 香定制', '2026-09-18', 4, '¥1280', '余 2/4', '白露老师', '工坊二层·私享室', '即将满员'),
new SessionItem(3, '亲子香气启蒙课', '2026-09-21', 2, '¥258', '余 12/15', '小满', '工坊一层·亲子区', '可预约'),
new SessionItem(4, '木质调专题研习', '2026-10-05', 3, '¥688', '余 5/8', '墨水', '工坊二层·研习室', '可预约'),
new SessionItem(5, '情侣对香手作', '2026-10-18', 3, '¥928', '余 4/6', '白露老师', '工坊二层·私享室', '可预约')
];
@State recipes: RecipeItem[] = [
new RecipeItem(1, '初雪的庭院', '冬日花木', 42, '檀香+鸢尾', 6, '白露老师', '进阶', '❄️'),
new RecipeItem(2, '六月果园', '夏日果香', 30, '葡萄柚+香草', 4, '小满', '入门', '🍑'),
new RecipeItem(3, '雨后苔原', '湿润绿调', 55, '广藿香+海盐', 8, '白露老师', '高级', '🌿'),
new RecipeItem(4, '旧书店', '纸张木质', 38, '乳香+麝香', 5, '墨水', '进阶', '📚'),
new RecipeItem(5, '焦糖布丁', '美食甜调', 26, '香草+檀香', 3, '甜度超标', '入门', '🍮'),
new RecipeItem(6, '月下白茶', '清透茶香', 34, '佛手柑+白茶', 5, '墨水', '入门', '🍵')
];
@State reviews: ReviewItem[] = [
new ReviewItem(1, '雾里看花', '🌸', '玫瑰低语', 9.2, '开场是带露水的青玫瑰,半小时后粉感收拢,像捧着一束还没拆封的花。', 320, '2小时前'),
new ReviewItem(2, '木星来客', '🪐', '乌木沉香', 9.6, '乌木+沉香+一丝烟熏,气场全开,冬天大衣上能留两天,回头率超高。', 508, '5小时前'),
new ReviewItem(3, '清晨六点', '⏰', '晨雾柑橘', 8.5, '通勤安全牌,柑橘青草干净利落,缺点是三小时就散,适合补香勤快的人。', 187, '8小时前'),
new ReviewItem(4, '海边的猫', '🐈', '海盐鼠尾', 9.0, '咸咸的海风混着鼠尾草,像在礁石上晒太阳,中性的恰到好处。', 276, '11小时前'),
new ReviewItem(5, '甜度超标', '🍰', '檀香奶昔', 8.8, '檀香打底上面铺了一层奶,甜而不腻,像温热的杏仁奶,睡前安利。', 233, '昨天')
];
fxTimer: number = -1;
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.bloom = !this.bloom;
}, 700);
}
aboutToDisappear(): void {
clearInterval(this.fxTimer);
}
// ============ 弹框工具 ============
openAdd(): void {
this.addNick = '';
this.addText = '';
this.addModal = true;
this.editModal = false;
this.delModal = false;
}
openEdit(idx: number): void {
this.editIdx = idx;
this.editTitle = this.sessions[idx].title;
this.editDate = this.sessions[idx].date;
this.addModal = false;
this.editModal = true;
this.delModal = false;
}
openDelRecipe(idx: number): void {
this.delTarget = 'recipe';
this.editIdx = idx;
this.addModal = false;
this.editModal = false;
this.delModal = true;
}
openDelSession(idx: number): void {
this.delTarget = 'session';
this.editIdx = idx;
this.addModal = false;
this.editModal = false;
this.delModal = true;
}
doAdd(): void {
if (this.addText.length > 0) {
const n = this.reviews.length + 1;
this.reviews.push(new ReviewItem(n, this.addNick.length > 0 ? this.addNick : '新鼻子', '🫧', '自由香评', 8.0, this.addText, 0, '刚刚'));
this.addModal = false;
}
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.sessions.length) {
this.sessions[this.editIdx].title = this.editTitle;
this.sessions[this.editIdx].date = this.editDate;
this.editModal = false;
}
}
doDel(): void {
if (this.delTarget === 'session') {
if (this.editIdx >= 0 && this.editIdx < this.sessions.length) {
this.sessions.splice(this.editIdx, 1);
this.delModal = false;
}
} else {
if (this.editIdx >= 0 && this.editIdx < this.recipes.length) {
this.recipes.splice(this.editIdx, 1);
this.delModal = false;
}
}
}
// ============ 特效层:香气粒子扩散 ============
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (s: number) => {
Column()
.width(scentR(this.tick) + s % 3)
.height(scentR(this.tick) + s % 3)
.borderRadius((scentR(this.tick) + s % 3) / 2)
.backgroundColor(s % 3 === 0 ? COLORS.rose : (s % 3 === 1 ? COLORS.lilac : COLORS.amber))
.opacity(this.bloom ? 0.35 : 0.12)
.position({ x: scentX(s, this.tick), y: scentY(s + 2, this.tick) })
}, (s: number) => s.toString())
ForEach([10, 11, 12], (s: number) => {
Text(s % 2 === 0 ? '🌸' : '✨')
.fontSize(9)
.opacity(this.bloom ? 0.8 : 0.2)
.position({ x: scentX(s, this.tick) + 10, y: scentY(s - 4, this.tick) })
}, (s: number) => s.toString())
}
.width('100%')
.height('100%')
.clip(true)
}
// ============ 头部(香品电商风,无动画) ============
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Column() { Text('📍').fontSize(15) }
.width(32).height(32).borderRadius(16).backgroundColor(COLORS.card2)
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Text('香氛纪·调香工坊').fontSize(11).fontColor(COLORS.sub)
Text('上海安福路店 · 今日营业').fontSize(9).fontColor(COLORS.rose)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Row({ space: 6 }) {
Column() { Text('🔍').fontSize(14) }
.width(30).height(30).borderRadius(15).backgroundColor(COLORS.card2)
.justifyContent(FlexAlign.Center)
Column() { Text('🛍️').fontSize(14) }
.width(30).height(30).borderRadius(15).backgroundColor(COLORS.card2)
.justifyContent(FlexAlign.Center)
}
}
.width('100%')
// 今日主打大卡:粉紫渐变
Row({ space: 12 }) {
Column({ space: 3 }) {
Text('秋日主打').fontSize(10).fontColor('#F3E8EF')
Text('雨后苔原 No.7').fontSize(17).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('广藿香 × 海盐 × 湿青苔 · 限量 100 瓶').fontSize(9).fontColor('#E8D5E0')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 5 }) {
Text('🌿').fontSize(34)
Text('¥728/50ml').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(14)
.borderRadius(16)
.linearGradient({
angle: 120,
colors: [['#C4708F', 0], ['#9B7FBF', 1]]
})
// 热门香料横滚
Scroll() {
Row({ space: 10 }) {
ForEach(scentList, (s: ScentItem) => {
Column({ space: 4 }) {
Text(s.icon).fontSize(18)
Text(s.name.length > 6 ? s.name.slice(0, 6) : s.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(s.note).fontSize(8).fontColor(COLORS.rose)
}
.width(88).padding({ top: 8, bottom: 8 }).borderRadius(10)
.backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
}, (s: ScentItem) => s.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(74)
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.bg)
}
// ============ 内容tab导航(7个,两排 4+3) ============
@Builder
subNav() {
Column({ space: 6 }) {
Row({ space: 6 }) {
ForEach(['精选', '香料库', '香评榜', '留香指数'], (t: string, i: number) => {
Column() {
Text(t)
.fontSize(11)
.fontColor(this.subTab === i ? COLORS.white : COLORS.sub)
.fontWeight(this.subTab === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.subTab === i ? COLORS.rose : COLORS.card2)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.subTab = i; })
}, (t: string) => t)
}
.width('100%')
Row({ space: 6 }) {
ForEach(['配方工坊', '体验预约', '调香师圈'], (t: string, i: number) => {
Column() {
Text(t)
.fontSize(11)
.fontColor(this.subTab === i + 4 ? COLORS.white : COLORS.sub)
.fontWeight(this.subTab === i + 4 ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.subTab === i + 4 ? COLORS.lilac : COLORS.card2)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.subTab = i + 4; })
}, (t: string) => t)
}
.width('100%')
}
.width('100%')
.padding({ left: 12, right: 12 })
}
// ============ Tab0 精选:三色橱窗横滚 + 本周榜单 ============
@Builder
tabPick() {
Scroll() {
Column({ space: 12 }) {
Scroll() {
Row({ space: 10 }) {
ForEach([0, 1, 2, 3], (b: number) => {
Column({ space: 6 }) {
Text(b === 0 ? '🌹 花香橱窗' : (b === 1 ? '🪵 木质橱窗' : (b === 2 ? '🍊 果香橱窗' : '🧂 咸香橱窗')))
.fontSize(14).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text(b === 0 ? '玫瑰/铃兰/茉莉' : (b === 1 ? '檀香/广藿香' : (b === 2 ? '佛手柑/葡萄柚' : '海盐/鼠尾草')))
.fontSize(8).fontColor('#F3E8EF')
Text(b % 2 === 0 ? '低至 8 折' : '买 3 送 1').fontSize(8)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8)
.backgroundColor('#FFFFFF33')
}
.width(150).height(90).padding(12).borderRadius(14)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
.backgroundColor(b === 0 ? '#C4708F' : (b === 1 ? '#8A6E82' : (b === 2 ? '#C89A5B' : '#6C8FB5')))
}, (b: number) => b.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
Text('本周香评 TOP5').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(reviewList, (r: ReviewItem, i: number) => {
if (i < 5) {
Row({ space: 10 }) {
Text((i + 1).toString())
.fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(i === 0 ? COLORS.rose : (i === 1 ? COLORS.lilac : COLORS.sub))
.width(24)
Column({ space: 2 }) {
Text(r.perfume).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(r.nick + ' · ' + r.time).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(r.score.toFixed(1)).fontSize(12).fontColor(scoreColor(r.score)).fontWeight(FontWeight.Bold)
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}
}, (r: ReviewItem) => r.id.toString())
Text('调香小知识').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 10 }) {
Column({ space: 4 }) {
Text('⏱️').fontSize(20)
Text('前中后调').fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text('挥发速度决定层次').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('💧').fontSize(20)
Text('酒精浓度').fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text('决定扩散与留香').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('🌸').fontSize(20)
Text('净油 vs 精油').fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text('工艺不同香气不同').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ Tab1 香料库:四列香料瓶宫格 ============
@Builder
tabScents() {
Scroll() {
Column({ space: 10 }) {
Text('原料香料柜(' + scentList.length.toString() + ' 种在架)')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
ForEach(scentList, (s: ScentItem) => {
if (s.id % 4 === 1) {
Column({ space: 4 }) {
Text(s.icon).fontSize(22)
Text(s.name.length > 6 ? s.name.slice(0, 6) : s.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(s.family).fontSize(7).fontColor(COLORS.rose)
Text(s.price).fontSize(8).fontColor(COLORS.musk).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
}, (s: ScentItem) => s.id.toString())
}
.width('100%')
Row({ space: 8 }) {
ForEach(scentList, (s: ScentItem) => {
if (s.id % 4 === 2) {
Column({ space: 4 }) {
Text(s.icon).fontSize(22)
Text(s.name.length > 6 ? s.name.slice(0, 6) : s.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(s.family).fontSize(7).fontColor(COLORS.rose)
Text(s.price).fontSize(8).fontColor(COLORS.musk).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
}, (s: ScentItem) => s.id.toString())
}
.width('100%')
Row({ space: 8 }) {
ForEach(scentList, (s: ScentItem) => {
if (s.id % 4 === 3) {
Column({ space: 4 }) {
Text(s.icon).fontSize(22)
Text(s.name.length > 6 ? s.name.slice(0, 6) : s.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(s.family).fontSize(7).fontColor(COLORS.rose)
Text(s.price).fontSize(8).fontColor(COLORS.musk).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
}, (s: ScentItem) => s.id.toString())
}
.width('100%')
Row({ space: 8 }) {
ForEach(scentList, (s: ScentItem) => {
if (s.id % 4 === 0) {
Column({ space: 4 }) {
Text(s.icon).fontSize(22)
Text(s.name.length > 6 ? s.name.slice(0, 6) : s.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(s.family).fontSize(7).fontColor(COLORS.rose)
Text(s.price).fontSize(8).fontColor(COLORS.musk).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
}, (s: ScentItem) => s.id.toString())
}
.width('100%')
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ Tab2 香评榜:大卡评论流 ============
@Builder
tabReviews() {
Scroll() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('香评榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Button().width(78).height(26).borderRadius(13).backgroundColor(COLORS.rose)
.onClick(() => { this.openAdd(); })
Text('+写香评').fontSize(9).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -64 })
}
.width('100%')
ForEach(this.reviews, (r: ReviewItem) => {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(r.avatar).fontSize(24)
Column({ space: 2 }) {
Text(r.nick).fontSize(11).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text(r.time).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 1 }) {
Text(r.score.toFixed(1)).fontSize(13).fontColor(scoreColor(r.score)).fontWeight(FontWeight.Bold)
Text(r.score >= 9.3 ? '口碑爆款' : (r.score >= 8.8 ? '值得一试' : '见仁见智')).fontSize(7).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Text('评 #' + r.perfume + '#').fontSize(9).fontColor(COLORS.lilac)
Text(r.text).fontSize(10).fontColor(COLORS.title).maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
Row({ space: 12 }) {
Text('🤍 ' + r.likes.toString()).fontSize(9).fontColor(COLORS.sub)
Text('💬 回应').fontSize(9).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text('再评一次').fontSize(8).fontColor(COLORS.blue)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (r: ReviewItem) => r.id.toString())
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ Tab3 留香指数:柱状图 + 香调结构 ============
@Builder
tabLast() {
Scroll() {
Column({ space: 12 }) {
Text('单品留香实测(小时)').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 10 }) {
ForEach(lastCharts, (c: LastChartItem) => {
Column({ space: 4 }) {
Text(c.val.toString() + 'h').fontSize(8).fontColor(c.color).fontWeight(FontWeight.Bold)
Column()
.width(20)
.height(lastBarH(c.val))
.borderRadius(6)
.backgroundColor(c.color)
Text(c.perfume.length > 4 ? c.perfume.slice(0, 4) : c.perfume).fontSize(7).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Center)
}, (c: LastChartItem) => c.perfume)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.alignItems(VerticalAlign.Bottom)
.height(230)
Text('一瓶香的四段旅程').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(noteList, (n: NoteItem, i: number) => {
Row({ space: 10 }) {
Text((i + 1).toString()).fontSize(10).fontColor(COLORS.white)
.width(20).height(20).borderRadius(10).backgroundColor(n.color)
.textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(n.label).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row()
.height(8).borderRadius(4).backgroundColor(n.color).width(noteW(n.pct))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(n.pct.toString() + '%').fontSize(9).fontColor(n.color).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card2)
}, (n: NoteItem) => n.label)
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ Tab4 配方工坊:步骤时间轴卡 ============
@Builder
tabRecipes() {
Scroll() {
Column({ space: 0 }) {
Text('我的配方手账').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%').padding({ bottom: 10 })
ForEach(this.recipes, (r: RecipeItem, i: number) => {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(r.icon).fontSize(18)
Text(r.drops.toString() + '滴').fontSize(8).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Column().width(2).layoutWeight(1).backgroundColor(COLORS.line).margin({ top: 4 })
}
.width(56)
.constraintSize({ minHeight: 104 })
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(r.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(r.level).fontSize(7).fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(levelColor(r.level))
}
Text(r.theme + ' · 基调 ' + r.base).fontSize(8).fontColor(COLORS.sub)
Text(r.steps.toString() + ' 步 · 作者 ' + r.author).fontSize(8).fontColor(COLORS.blue)
Row({ space: 8 }) {
Button().width(60).height(24).borderRadius(12).backgroundColor(COLORS.card2)
.onClick(() => { this.openDelRecipe(i); })
Text('删除配方').fontSize(8).fontColor(COLORS.red).margin({ left: -60 })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}, (r: RecipeItem) => r.id.toString())
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ Tab5 体验预约:门店卡列表 ============
@Builder
tabSessions() {
Scroll() {
Column({ space: 10 }) {
Text('工坊体验预约').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(this.sessions, (s: SessionItem, i: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('🕯️').fontSize(22)
Column({ space: 2 }) {
Text(s.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('📍 ' + s.place + ' · ' + s.teacher).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(s.status === '即将满员' ? '即将满员' : '可预约').fontSize(7)
.fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor(s.status === '即将满员' ? COLORS.gold : COLORS.green)
}
.width('100%')
Row({ space: 10 }) {
Text('📅 ' + s.date).fontSize(9).fontColor(COLORS.blue)
Text('⏱ ' + s.hours.toString() + ' 小时').fontSize(9).fontColor(COLORS.sub)
Text('🎫 ' + s.quota).fontSize(9).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text(s.price).fontSize(11).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
}
.width('100%')
Row({ space: 8 }) {
Button().width(72).height(26).borderRadius(13).backgroundColor(COLORS.card2)
.onClick(() => { this.openEdit(i); })
Text('改约').fontSize(9).fontColor(COLORS.sub).margin({ left: -60 })
Button().width(72).height(26).borderRadius(13).backgroundColor(COLORS.card2)
.onClick(() => { this.openDelSession(i); })
Text('取消预约').fontSize(9).fontColor(COLORS.red).margin({ left: -72 })
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
.border({ width: 1, color: COLORS.line })
}, (s: SessionItem) => s.id.toString())
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ Tab6 调香师圈:左头像右文信息流 ============
@Builder
tabCircle() {
Scroll() {
Column({ space: 10 }) {
Text('调香师动态').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach([['白露老师', '🕯️', '新到的鸢尾根黄油开罐了,粉感浓郁到爆炸,做"初雪的庭院"后调绝佳,限量 3 份配方位。'],
['小满', '🌾', '亲子课上小朋友调出了"西瓜泡泡糖",意外的好闻,孩子的鼻子果然没有被驯化。'],
['墨水', '🖋️', '"旧书店"配方 v3:把乳香比例降到 12%,纸张感立刻出来了,木质不抢戏。'],
['甜度超标', '🍭', '测试香草醛不同产地差异中:马达加斯加的奶感最圆润,墨西哥的偏木,笔记已更新。'],
['格拉斯学徒', '🕊️', '在格拉斯交换的第 40 天,看了茉莉采摘,凌晨四点开始,一斤花出一克净油,敬意+1。']], (a: string[]) => {
Row({ space: 10 }) {
Text(a[1]).fontSize(28)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(a[0]).fontSize(11).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text('认证调香师').fontSize(7).fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(COLORS.lilac)
}
Text(a[2]).fontSize(10).fontColor(COLORS.title).maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
}, (a: string[]) => a[0])
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ 主tab1 香料库页 ============
@Builder
pageScentLib() {
Scroll() {
Column({ space: 10 }) {
Text('🧪 香料库').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(scentList, (s: ScentItem) => {
Row({ space: 10 }) {
Column() { Text(s.icon).fontSize(22) }
.width(46).height(46).borderRadius(12).backgroundColor(COLORS.card2)
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Row({ space: 4 }) {
Text(s.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(s.hot).fontSize(9)
}
Text(s.family + ' · ' + s.note + ' · ' + s.origin).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 2 }) {
Text(s.price).fontSize(10).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text('库存 ' + s.stock.toString()).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (s: ScentItem) => s.id.toString())
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ 主tab2 工坊页 ============
@Builder
pageWorkshop() {
Scroll() {
Column({ space: 10 }) {
Text('🕯️ 工坊活动').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('12').fontSize(18).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text('在售香料').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('5').fontSize(18).fontColor(COLORS.lilac).fontWeight(FontWeight.Bold)
Text('体验课程').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('386').fontSize(18).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('累计出瓶').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(sessionList, (s: SessionItem) => {
Column({ space: 5 }) {
Text(s.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('📅 ' + s.date + ' · ' + s.hours.toString() + '小时 · ' + s.quota).fontSize(8).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('👩🏫 ' + s.teacher).fontSize(8).fontColor(COLORS.blue)
Column().layoutWeight(1)
Text(s.price).fontSize(11).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
}, (s: SessionItem) => s.id.toString())
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ 主tab3 我的页 ============
@Builder
pageMine() {
Scroll() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🫧').fontSize(40)
Column({ space: 3 }) {
Text('新鼻子 David').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('闻香笔记 28 篇 · 调香等级 Lv.4').fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%').padding(16).borderRadius(16)
.linearGradient({ angle: 100, colors: [['#F4EBF0', 0], ['#FFFFFF', 1]] })
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('6').fontSize(18).fontColor(COLORS.rose).fontWeight(FontWeight.Bold)
Text('我的配方').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('28').fontSize(18).fontColor(COLORS.lilac).fontWeight(FontWeight.Bold)
Text('香评笔记').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('156').fontSize(18).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('获赞').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('闻香徽章').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
Column({ space: 3 }) { Text('👃').fontSize(22); Text('百香初闻').fontSize(8).fontColor(COLORS.sub) }
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) { Text('🧪').fontSize(22); Text('第一瓶').fontSize(8).fontColor(COLORS.sub) }
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) { Text('🌹').fontSize(22); Text('花香控').fontSize(8).fontColor(COLORS.sub) }
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('我的香柜').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(['🌿 雨后苔原 No.7 · 剩 32ml', '🌹 玫瑰低语 · 剩 45ml', '🍵 月下白茶 · 剩 8ml · 待补'], (s: string) => {
Text(s).fontSize(10).fontColor(COLORS.title)
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (s: string) => s)
}
.width('100%')
.padding(12)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
// ============ 遮罩 ============
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
// ============ 新增弹框 ============
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发表香评').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '昵称(可留空)', text: this.addNick })
.fontSize(11).height(38).borderRadius(10)
.backgroundColor(COLORS.card2).fontColor(COLORS.title)
.onChange((v: string) => { this.addNick = v; })
TextInput({ placeholder: '今天闻到了什么?写下你的感受…', text: this.addText })
.fontSize(11).height(60).borderRadius(10)
.backgroundColor(COLORS.card2).fontColor(COLORS.title)
.onChange((v: string) => { this.addText = v; })
Row({ space: 10 }) {
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
.onClick(() => { this.addModal = false; })
Text('取消').fontSize(11).fontColor(COLORS.sub).margin({ left: -66 })
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.rose)
.onClick(() => { this.doAdd(); })
Text('发布').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -66 })
}
.width('100%')
}
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
// ============ 编辑弹框 ============
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('调整体验预约').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '课程名称', text: this.editTitle })
.fontSize(11).height(38).borderRadius(10)
.backgroundColor(COLORS.card2).fontColor(COLORS.title)
.onChange((v: string) => { this.editTitle = v; })
TextInput({ placeholder: '日期 如 2026-09-12', text: this.editDate })
.fontSize(11).height(38).borderRadius(10)
.backgroundColor(COLORS.card2).fontColor(COLORS.title)
.onChange((v: string) => { this.editDate = v; })
Text('提示:signature 定制课改约需提前 48 小时').fontSize(8).fontColor(COLORS.sub).width('100%')
Row({ space: 10 }) {
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
.onClick(() => { this.editModal = false; })
Text('取消').fontSize(11).fontColor(COLORS.sub).margin({ left: -66 })
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.lilac)
.onClick(() => { this.doEdit(); })
Text('保存').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -66 })
}
.width('100%')
}
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
// ============ 删除确认弹框 ============
@Builder
delModalBody() {
Column({ space: 12 }) {
Text('确认操作').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(this.delTarget === 'session' ? '取消预约后名额将立即释放,定金 3 个工作日内退回。' : '删除配方后手账记录将无法恢复,确认继续吗?')
.fontSize(10).fontColor(COLORS.sub).textAlign(TextAlign.Center)
Row({ space: 10 }) {
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
.onClick(() => { this.delModal = false; })
Text('再想想').fontSize(11).fontColor(COLORS.sub).margin({ left: -82 })
Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.red)
.onClick(() => { this.doDel(); })
Text('确认删除').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -94 })
}
.width('100%')
}
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
// ============ 底部导航 ============
@Builder
bottomBar() {
Row({ space: 0 }) {
ForEach(NAV_LIST, (ni: NavItem, i: number) => {
Column({ space: 3 }) {
Text(ni.icon).fontSize(18)
Text(ni.label)
.fontSize(9)
.fontColor(this.mainTab === i ? COLORS.rose : COLORS.sub)
.fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.mainTab = i; this.subTab = 0; })
}, (ni: NavItem) => ni.label)
}
.width('100%')
.backgroundColor(COLORS.card)
.border({ width: 1, color: COLORS.line })
}
// ============ 主构建 ============
build() {
Stack() {
Column() {
this.header()
if (this.mainTab === 0) {
Column() {
this.subNav()
if (this.subTab === 0) {
this.tabPick()
} else if (this.subTab === 1) {
this.tabScents()
} else if (this.subTab === 2) {
this.tabReviews()
} else if (this.subTab === 3) {
this.tabLast()
} else if (this.subTab === 4) {
this.tabRecipes()
} else if (this.subTab === 5) {
this.tabSessions()
} else {
this.tabCircle()
}
}
.layoutWeight(1)
.width('100%')
.alignItems(HorizontalAlign.Center)
.backgroundColor(COLORS.bg)
} else if (this.mainTab === 1) {
this.pageScentLib()
} else if (this.mainTab === 2) {
this.pageWorkshop()
} else {
this.pageMine()
}
this.bottomBar()
}
.width('100%')
.height('100%')
// 特效层仅覆盖内容区
Column() {
this.fxLayer()
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End)
.hitTestBehavior(HitTestMode.None)
// 弹框层
if (this.addModal) {
Column() {
this.modalOverlay(() => { this.addModal = false; })
Column() {
this.addModalBody()
}
.constraintSize({ maxHeight: '80%' })
.zIndex(999)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
if (this.editModal) {
Column() {
this.modalOverlay(() => { this.editModal = false; })
Column() {
this.editModalBody()
}
.constraintSize({ maxHeight: '70%' })
.zIndex(999)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
if (this.delModal) {
Column() {
this.modalOverlay(() => { this.delModal = false; })
Column() {
this.delModalBody()
}
.constraintSize({ maxHeight: '60%' })
.zIndex(999)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
6.7 总结

代码风格统一——链式调用、ForEach 键值、layoutWeight 弹性布局、“Button + 负边距 Text"按钮写法——体现了开发者对 ArkUI API 的熟练掌握。注释以中文蓝图式书写,既导航了结构,也传递了设计意图。从这份代码中可以得到几点启示:第一,声明式 UI 的核心是"状态驱动视图”,把状态变量设计好,视图自然清晰;第二,@Builder 是组织大型 build 的利器,按职责拆分 Builder 比拆分独立组件更适合中小型应用;第三,纯函数是"值到视觉"映射的最佳归宿,既可测试又可复用;第四,色彩、数据、逻辑三层解耦是可维护性的基石;第五,行业氛围与专业内容的协同,是垂类电商差异化的关键。这些启示对任何希望在 HarmonyOS 生态中打造高质量垂类应用的开发者,都具有参考价值。
更多推荐
所有评论(0)