引言

当香水从专柜的奢侈品进化为Z世代的DIY手作体验,一场关于"拼多多风格×香薰调香社区"的嗅觉革命正在美妆圈蔓延。多多香薰调香正是这样一款概念应用——它将精油选购、调香工作台、配方库、瓶器器具、香友社区、个人中心,全部浓缩进一个 ArkTS 单文件之中。

不同于传统香水 App 的品牌展示路线,多多香薰调香选择了"拼多多风格·DIY香水/香薰调香社区"的定位:薰衣夜 #1E1033 深紫铺底,琥珀金 #FFB300 作为主强调色贯穿按钮、Tab激活、价格、评分,紫罗兰 #9D6DD1 作为辅助强调色用于香调类型、标签,如同深夜调香实验室的神秘氛围。Tab图标缩放、精油卡片按压缩放、调香滴数跳动、比例条动画、强度条动画、配方点赞缩放、社区评分点亮——7 组交互动画让整个界面如同真实的调香实验室体验。

6 个 Tab 覆盖完整业务链路:精油馆浏览 18 支精油并购买、调香工作台 6 支已选精油滴数微调并保存配方、配方库浏览 12 个热门配方并点赞编辑删除、瓶器馆浏览 15 件瓶器器具、香友圈浏览 10 条香评并点亮评分、我的管理 8 支我的调香与 6 笔购买记录与香型偏好与调香课预约。5 套弹框各具特色:购买精油(容量+数量stepper+合计)、保存调香(名称+香调类型+描述+可见范围)、编辑配方(名称+精油滴数+难度+备注)、删除配方确认(红色警告)、预约调香课(课程类型+时间+地点+费用)。

本文将从项目概述、色彩系统、架构设计、数据模型、六大 Tab、五大弹框、交互动画、精油与调香系统、配方与瓶器系统、社区与我的系统、性能优化、香薰调香文化、DIY香水生活方式、开发环境搭建共十五个维度,对多多香薰调香进行深度解析,文末附完整 ArkTS 源码。

多多香薰调香主界面


第一章 项目概述与设计理念

1.1 项目定位

多多香薰调香是一款面向拼多多风格DIY香水/香薰调香社区的概念应用,核心定位是"精油选购+调香工作台+配方库+瓶器器具+香友社区"五位一体的DIY香薰电商 App 风格。目标用户是 18-35 岁的香薰爱好者、DIY手作玩家、香水入门者、Z世代美妆消费者。应用场景包括:选购精油、调配香水、保存配方、购买瓶器、社区晒香、预约调香课。

1.2 设计理念

深夜调香实验室美学:以薰衣夜 #1E1033 深紫为背景,琥珀金 #FFB300 作为主强调色贯穿按钮、Tab激活、价格、评分、强度条,紫罗兰 #9D6DD1 作为辅助强调色用于香调类型、标签、课程,如同深夜调香实验室的神秘氛围。深紫背景营造沉浸感,琥珀金在深紫上具有强烈的视觉穿透力,如同实验台上的烛光。

DIY手作风:调香工作台的滴数微调、比例条动画、强度计、保存配方,完全复现真实调香实验室的手作体验。精油卡片的拉丁学名、纯度、香调标签,传递专业感。配方库的前中后调结构、精油滴数、难度标签、人气条,如同调香师的配方笔记。

高转化与专业感并存:大量使用拼多多式高转化元素(满99包邮、限时折扣、新香首发、30ml装更划算、破损必赔),同时保持调香专业感(拉丁学名、GC/MS检测、冷压蒸馏、前中后调结构、纯度百分比)。这种"拼多多式价格+专业级内容"的组合,降低了DIY调香的入门门槛。

1.3 核心功能矩阵

模块 核心功能 数据量
精油 香调筛选、双列宫格、按压缩放、购买弹框 18支/4香调
调香 6支已选精油、滴数微调、比例条、强度计、保存配方 6支精油
配方 12个热门配方、点赞缩放、人气条、编辑、删除 12配方/3难度
瓶器 三列紧凑宫格、香水瓶/扩香瓶/香薰炉分类 15件瓶器
社区 10条香评、评分点亮动画、点赞、点击卡片点亮 10条香评
我的 资料卡、香型偏好条形、8支我的调香、6笔购买记录、调香课预约 8调香/6购买

1.4 技术亮点

  • 单文件 ArkTS 零依赖:约 1400 行代码在一个 .ets 文件中,复制即运行
  • 7 组交互动画:Tab图标缩放(scale 1.15)、精油按压缩放(scale 0.96)、调香滴数跳动(scale 1.45)、比例条动画(350ms)、强度条动画(350ms)、配方点赞缩放(scale 1.25)、社区评分点亮(opacity 0.15→1,500ms)
  • 23 个全局纯函数:noteColor、oilsByNote、volumePrice、dropsOfNote、totalDrops、noteRatioW、withBlendDrops、strengthLevel、strengthColor、strengthBarW、difficultyColor、withRecipeLoved、withoutRecipe、withOilDrops、withRecipeSaved、ratingStars、isRevealed、toggleReveal、profileColor、coursePrice、courseDesc
  • 5 差异化弹框:购买精油 / 保存调香 / 编辑配方 / 删除配方确认 / 预约调香课
  • 调香工作台:6支精油滴数微调(0-20滴),前中后调比例条实时更新,香气强度计(空瓶/淡雅/均衡/浓郁/强烈)
  • 配方系统:12个配方含前中后调结构、精油滴数、难度(入门/进阶/大师)、人气条、点赞收藏
  • 香型偏好可视化:5维香型偏好条形图(花香90/木香74/果香66/草本52/香料38)
  • 社区评分点亮:点击卡片点亮评分星星,opacity 从 0.15 渐变到 1,500ms EaseOut

第二章 色彩系统与视觉风格

2.1 色彩体系

多多香薰调香定义了 10 色完整调色板(ScentPalette 接口),分为四个层级:

基础层(薰衣夜深紫)

  • bg #1E1033:应用背景,薰衣夜深紫
  • card #2A1A40:卡片背景,深紫
  • cardDeep #221334:深层卡片/头部,更深紫
  • chip #33224A:标签/搜索栏背景,紫褐

强调层(琥珀金 × 紫罗兰)

  • primary #FFB300:琥珀金,主按钮、Tab激活、价格、评分、强度条、人气条
  • secondary #9D6DD1:紫罗兰,香调类型、标签、课程、关注数
  • warn #FF7043:橙红,警告、删除、HOT标签
  • white #FFFFFF:纯白

文字层

  • text #E8DEF0:主文字,浅紫白
  • textSub #9B7AB8:次要文字,中紫

2.2 色彩应用策略

琥珀金 #FFB300 是应用的灵魂色,承担"行动召唤与价值强调"角色:底部 Tab 激活文字与图标、主操作按钮(立即购买/保存/确认预约)、价格显示(¥68)、评分星星(★★★★★)、强度条(浓郁/强烈)、人气条(配方热度)、课程费用。琥珀金在薰衣夜深紫背景上具有强烈的视觉穿透力,如同实验台上的烛光,是深夜调香实验室的核心光源。

紫罗兰 #9D6DD1 承担"香调与分类"角色:香调类型标签(花香/木香/果香/东方香)、配方香调、课程类型、关注数、瓶器类型标签。紫罗兰与琥珀金形成冷暖对撞,在数据可视化中区分"分类/标签"与"行动/价值"。

薰衣夜 #1E1033 承担"深夜实验室氛围"角色:应用背景、卡片间留白、弹框遮罩外区域。深紫色调营造沉浸感和神秘感,符合调香实验室的深夜氛围,与琥珀金形成高对比,让强调色更加突出。

2.3 深夜实验室渐变与质感

应用大量使用纯色块构建视觉层次:

  • 精油卡片:纯色深紫背景 #2A1A40,香调色边框(前调金/中调紫/后调橙)
  • 调香比例条:前调金 #FFD54F / 中调紫 #B388FF / 后调橙 #FF8A65,三色分段
  • 强度条:淡雅紫 #9D6DD1 / 均衡金 #FFB300 / 浓郁橙 #FF8A65 / 强烈红 #FF7043,四色渐变
  • 配方人气条:琥珀金 #FFB300 填充
  • 香型偏好条:花香粉 #F48FB1 / 木褐 #A1887F / 果金 #FFD54F / 草绿 #81C784 / 香料橙 #FF8A65,五色分类
  • 社区评分星星:琥珀金 #FFB300,点亮时 opacity 1,未点亮时 opacity 0.15

拼多多式营销元素是多多香薰调香的标志性设计:满99包邮、限时折扣、新香首发、30ml装更划算、破损必赔、顺丰包邮。这些元素共同营造拼多多式的高转化电商购物氛围,同时保持调香专业感。

不对称圆角贯穿全应用:卡片使用 12-14vp 统一圆角,弹框使用顶部 20vp 圆角(底部抽屉式)或 18vp 圆角(居中卡片式),搜索栏 17vp 圆角,按钮 15-20vp 圆角。

2.4 滴数跳动与评分点亮

项目通过交互动画模拟真实调香实验室体验:

  • 滴数跳动:调香工作台点击 +/− 调整滴数时,数字 scale 从 1.0 到 1.45,180ms EaseOut,如同滴管滴下精油的跳动感
  • 比例条动画:滴数变化时,前中后调比例条宽度平滑过渡,350ms EaseOut,如同香调比例实时变化
  • 强度条动画:总滴数变化时,强度条宽度平滑过渡,350ms EaseOut,强度等级(空瓶/淡雅/均衡/浓郁/强烈)实时更新
  • 评分点亮:社区香评卡片点击时,评分星星 opacity 从 0.15 渐变到 1,500ms EaseOut,如同点亮一盏香氛灯
  • Tab图标缩放:底部 Tab 切换时,激活图标 scale 从 1.0 到 1.15,200ms EaseOut,如同选中时的微弹反馈
  • 精油按压缩放:精油卡片点击时 scale 从 1.0 到 0.96,120ms EaseOut,如同按压精油瓶的反馈
  • 配方点赞缩放:配方卡片点赞时爱心 scale 从 1.0 到 1.25,200ms EaseOut,点赞满足感

这些动画共同营造真实调香实验室的交互反馈,让静态界面"活"起来。滴数跳动是本应用的特色动画,每次调整滴数时数字放大跳动,如同滴管滴下精油的真实感。评分点亮是社区Tab的特色交互,点击卡片点亮评分星星,如同点亮一盏香氛灯,增加神秘感和仪式感。

多多香薰调香色彩与视觉风格


第三章 项目架构与文件组织

3.1 单文件组件化架构

多多香薰调香采用单文件组件化架构,所有代码在一个 2022.ets 文件中,通过 @Component 装饰器划分为约 9 个独立组件(6 Tab + 头部 + 内联弹框)。代码量约 1400 行,处于单文件架构的合理范围。

3.2 组件层次结构

ScentApp (@Entry 根组件)
├── 顶部标题栏(ScentHeader:Logo+搜索+快捷入口)
├── 内容区 Stack
│   ├── 6 个 Tab 内容组件(6 选 1)
│   │   ├── OilContent(精油)
│   │   ├── BlendContent(调香)
│   │   ├── RecipeContent(配方)
│   │   ├── VesselContent(瓶器)
│   │   ├── CommunityContent(社区)
│   │   └── MineContent(我的)
│   └── 弹框(各Tab内联管理,zIndex 999)
│       ├── 购买精油弹框(OilContent内)
│       ├── 保存调香弹框(BlendContent内)
│       ├── 编辑配方弹框(RecipeContent内)
│       ├── 删除配方确认弹框(RecipeContent内)
│       └── 预约调香课弹框(MineContent内)
└── 底部 Tab 导航栏(6 项,图标缩放动画)

根组件管理当前 Tab(activeTab)。每个 Tab 组件内部管理自己的弹框显隐状态和交互状态,这种内联管理方式减少了根组件的状态复杂度。

3.3 @Builder 自定义构建函数

项目使用多个 @Builder 方法封装可复用 UI 片段:tabItem(底部Tab项,含图标缩放动画)、modalOverlay(弹框遮罩层)、noteChip(香调筛选标签)、ratioBar(香调比例条)、buyModal(购买精油弹框)、saveModal(保存调香弹框)、editModal(编辑配方弹框)、deleteModal(删除配方确认弹框)、courseModal(预约调香课弹框)。

3.4 弹框架构设计

弹框采用"遮罩层 + 内容层"Stack 叠加模式,每个 Tab 组件内部管理自己的弹框。5 套弹框各具特色:

  1. 购买精油:精油回显 + 容量chips(5/10/30ml)+ 数量stepper + 合计 + 立即购买(底部抽屉式)
  2. 保存调香:配方名称输入 + 香调类型chips(花香/木香/果香/东方香)+ 香调描述TextArea + 可见范围(公开/私密)+ 保存(居中卡片式)
  3. 编辑配方:配方名称输入 + 精油滴数微调 + 难度chips(入门/进阶/大师)+ 备注TextArea + 保存修改(底部抽屉式)
  4. 删除配方确认:⚠️警告图标 + 配方名称回显 + 红色警告说明 + 取消/确认删除(居中警示式)
  5. 预约调香课:课程类型chips(入门/进阶/专题)+ 上课时间chips(4时段)+ 地点讲师信息卡 + 费用 + 立即预约(底部抽屉式)

第四章 数据模型与全局状态管理

4.1 接口定义体系

项目定义 11 个 TypeScript 接口描述数据模型:OilItem(精油)、BlendOil(调香已选精油)、OilDrop(配方精油滴数)、RecipeItem(配方)、VesselItem(瓶器)、ReviewItem(香评)、MyBlendItem(我的调香)、PurchaseItem(购买记录)、ScentProfileBar(香型偏好)、ScentPalette(调色板)。

4.2 全局数据常量

约 15 组全局 const 常量:核心业务数据(OILS 18支 / BLEND_OILS 6支 / RECIPES 12个 / VESSELS 15件 / REVIEWS 10条 / MY_BLENDS 8支 / PURCHASES 6笔 / SCENT_PROFILE 5维)、配置数据(VOL_OPTIONS 3容量 / PROFILES 4香调 / DIFFICULTIES 3难度 / COURSE_TYPES 3课程 / COURSE_SLOTS 4时段)。

4.3 三级状态管理策略

  • 第一级 全局常量(const):业务数据和配置,任何组件可直接读取
  • 第二级 组件局部状态(@State):组件内交互状态,如 curNote(当前香调筛选)、pressedId(按压索引)、showBuy(购买弹框)、buyVol/buyQty(购买选项)、blendOils(调香已选精油,可更新)、bumpId/bumpTick(滴数跳动)、recipes(配方,可点赞编辑删除)、revealedIds(社区已点亮评分)、showCourse(调香课弹框)
  • 第三级 根组件协调状态(@State):跨组件状态,如 activeTab(当前 Tab)

4.4 全局纯函数

23 个全局纯函数处理数据转换和业务逻辑:

  • noteColor(note):根据香调返回对应颜色(前调金/中调紫/后调橙)
  • oilsByNote(list, note):按香调筛选精油
  • volumePrice(price, vol):根据容量计算价格(5ml×0.6/10ml原价/30ml×2.6)
  • dropsOfNote(list, note):统计某香调的总滴数
  • totalDrops(list):统计总滴数
  • noteRatioW(list, note):计算某香调的比例百分比
  • withBlendDrops(list, id, drops):更新调香精油滴数(边界0-20)
  • strengthLevel(total):根据总滴数返回强度等级(空瓶/淡雅/均衡/浓郁/强烈)
  • strengthColor(total):根据总滴数返回强度颜色
  • strengthBarW(total):根据总滴数返回强度条宽度百分比
  • difficultyColor(d):根据难度返回颜色(入门紫/进阶金/大师红)
  • withRecipeLoved(list, id):切换配方点赞状态
  • withoutRecipe(list, id):删除配方
  • withOilDrops(list, name, drops):更新配方精油滴数(边界0-15)
  • withRecipeSaved(list, id, name, oils, difficulty):保存配方修改
  • ratingStars®:评分星星字符串
  • isRevealed(list, id):判断评分是否已点亮
  • toggleReveal(list, id):切换评分点亮状态
  • profileColor(f):根据香调返回偏好条颜色
  • coursePrice(t):根据课程类型返回价格(入门99/进阶199/专题328)
  • courseDesc(t):根据课程类型返回描述

纯函数相同输入永远产生相同输出,无副作用,易于测试和复用。withBlendDrops 和 withOilDrops 纯函数实现滴数边界控制(0-20/0-15),避免滴数越界。strengthLevel 和 strengthColor 纯函数根据总滴数动态计算强度等级和颜色,是调香工作台的核心逻辑。toggleReveal 纯函数实现社区评分点亮状态切换,返回新数组避免直接修改状态。


第五章 六大 Tab 页面深度解析

5.1 精油 Tab(OilContent)

精油是入口,承担"香调筛选→浏览精油→按压缩放→购买"路径。页面结构:

  1. 精油馆标题:🧪精油馆 + 单一产区·GC/MS检测·冷压蒸馏 + 满99包邮标签
  2. 香调筛选 chips(4 香调+全调):全调/前调/中调/后调,选中琥珀金背景深紫字,oilsByNote 纯函数筛选
  3. 双列宫格(18 支,按香调筛选):每卡含emoji+香调标签(noteColor 纯函数,前调金/中调紫/后调橙)+科属+拉丁学名+纯度+价格(琥珀金加粗)+容量+购买按钮,按压缩放动画(scale 0.96,120ms EaseOut)
  4. 到底提示

按压缩放动画是精油Tab的核心交互:每张精油卡片 onTouch 事件监听 Down/Up,Down 时 pressedId=o.id,Up 时 pressedId=-1,scale 根据 pressedId===o.id 在 0.96 和 1.0 之间切换,120ms EaseOut 动画。这种按压反馈如同按压精油瓶的真实感,120ms 较短时长模拟快速按压。

精油卡片设计:每卡左侧emoji(28vp),右侧香调标签+科属,HOT标签(橙红边框),下方精油名称(13vp)+拉丁学名(8vp,1行省略),分割线,价格(¥68,琥珀金15vp加粗)+容量+购买按钮。卡片边框使用香调色半透明(noteColor(o.note)+‘66’),1.5vp宽度,不同香调的精油卡片有不同颜色边框,直观区分。

购买精油弹框:点击购买按钮触发,含精油回显(emoji+名称+拉丁学名+纯度+科属·香调)+ 容量chips(5/10/30ml,volumePrice 纯函数计算价格)+ 数量stepper(−/+,1-20瓶)+ 合计(volumePrice×数量)+ 单价说明(30ml装更划算)+ 立即购买按钮。

精油Tab

5.2 调香 Tab(BlendContent)

调香是核心工作台,承担"查看比例→滴数微调→强度计→保存配方"路径。页面结构:

  1. 当前配方卡:⚗️当前配方 + 总滴数 + 前中后调滴数统计 + 三条比例条(noteRatioW 纯函数,前调金/中调紫/后调橙,350ms EaseOut动画)
  2. 所选精油·滴数微调标题
  3. 精油列表(6 支已选精油):每支含emoji+名称+香调标签 + −按钮 + 滴数(滴数跳动动画 scale 1.45,180ms EaseOut)+ +按钮 + 滴标签,withBlendDrops 纯函数更新滴数(边界0-20)
  4. 香气强度卡:🔥香气强度 + 强度等级(strengthLevel 纯函数,空瓶/淡雅/均衡/浓郁/强烈)+ 强度条(strengthBarW 纯函数,strengthColor 纯函数四色,350ms EaseOut动画)+ 滴数建议 + 保存配方/清空重来按钮

滴数跳动动画是调香Tab的特色交互:点击 +/− 调整滴数时,bumpId=o.id,bumpTick=!bumpTick,滴数数字 scale 根据 bumpId===o.id && bumpTick 在 1.45 和 1.0 之间切换,180ms EaseOut 动画。每次调整滴数时数字放大跳动,如同滴管滴下精油的真实感,增加调香的手作体验。

比例条动画:滴数变化时,前中后调比例条宽度(noteRatioW 纯函数)平滑过渡,350ms EaseOut 动画。比例条背景紫褐 #33224A,前景香调色(前调金#FFD54F/中调紫#B388FF/后调橙#FF8A65),圆角4vp。比例条右侧显示百分比(Math.round)。三条比例条直观展示前中后调的香调结构。

香气强度计:总滴数变化时,强度条宽度(strengthBarW 纯函数,total/40×100%,上限100%)平滑过渡,350ms EaseOut 动画。强度等级(strengthLevel 纯函数)根据总滴数动态计算:0滴=空瓶、<10滴=淡雅(紫#9D6DD1)、<20滴=均衡(金#FFB300)、<30滴=浓郁(橙#FF8A65)、≥30滴=强烈(红#FF7043)。强度计如同调香实验室的强度检测仪,实时反馈香气浓度。

保存调香弹框:点击保存配方触发,含配方名称输入 + 香调类型chips(花香/木香/果香/东方香,选中紫罗兰背景深紫字)+ 香调描述TextArea + 可见范围(公开/私密,选中琥珀金背景深紫字)+ 取消/保存按钮。居中卡片式弹框,86%宽度,18vp圆角。

调香Tab

5.3 配方 Tab(RecipeContent)

配方是配方库,承担"浏览配方→点赞缩放→人气条→编辑删除"路径。页面结构:

  1. 热门配方标题:📜热门配方 + 一键套用·可微调标签
  2. 配方列表(12 个):每个含配方名+创作者+香调 + 前中后调标签(紫罗兰背景)+ 精油滴数标签(紫褐背景)+ 难度标签(difficultyColor 纯函数,入门紫/进阶金/大师红)+ 人气条(琥珀金填充,popularity%)+ 编辑/删除按钮 + 爱心点赞(点赞缩放动画 scale 1.25,200ms EaseOut)+ 点赞数
  3. 提示:💡配方仅供参考,请根据个人嗅觉记忆微调滴数

点赞缩放动画是配方Tab的核心交互:每个配方卡片的爱心(🤍/❤️)点击时 withRecipeLoved 纯函数切换点赞状态,爱心 scale 从 1.0 到 1.25,200ms EaseOut 动画。点赞时爱心从白色🤍变为红色❤️并放大1.25倍,点赞数+1;取消点赞时爱心缩小变回白色,点赞数-1。1.25倍放大增加点赞的满足感。

配方结构设计:每个配方含前中后调标签(如"前调·果香"“中调·花香”“后调·木香”,紫罗兰背景#3A2650),精油滴数标签(如"甜橙×4滴"“薰衣草×5滴”,紫褐背景#33224A),难度标签(入门紫/进阶金/大师红,半透明背景),人气条(琥珀金填充,popularity%宽度,紫褐背景)。配方结构如同调香师的配方笔记,专业且易读。

编辑配方弹框:点击编辑触发,含配方名称输入 + 精油滴数微调(每支−/+,withOilDrops 纯函数,边界0-15)+ 难度chips(入门/进阶/大师)+ 备注TextArea + 取消/保存修改按钮(withRecipeSaved 纯函数保存)。底部抽屉式弹框。

删除配方确认弹框:点击删除触发,含⚠️警告图标(橙红)+ 删除配方标题 + 配方名称回显 + 红色警告说明(删除后无法恢复,配方将移出配方库)+ 取消/确认删除按钮(确认删除橙红背景,withoutRecipe 纯函数删除)。居中警示式弹框,78%宽度,18vp圆角。

配方Tab

5.4 瓶器 Tab(VesselContent)

瓶器是瓶器器具馆,承担"浏览瓶器→三列宫格"路径。页面结构:

  1. 瓶器与器具标题:🏺瓶器与器具 + 顺丰包邮·破损必赔标签
  2. 三列紧凑宫格(15 件):每件含emoji(30vp)+ 名称(11vp,1行省略)+ 类型标签(紫罗兰背景#3A2650)+ 材质·容量(8vp,1行省略)+ 价格(琥珀金14vp加粗)

三列紧凑宫格设计:使用 Flex({wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween}) 实现三列布局,每卡宽度 31.8%,自动换行。每卡居中对齐,emoji 30vp,名称 11vp 1行省略,类型标签 9vp 紫罗兰背景,材质·容量 8vp 1行省略,价格 14vp 琥珀金加粗。三列紧凑布局最大化展示瓶器数量,如同电商商品列表。

瓶器分类:15件瓶器分为三类:香水瓶(6件:琥珀水晶瓶/磨砂滚珠瓶/鎏金细颈瓶/手工吹制圆瓶/复古喷泵旅行瓶)、扩香瓶(5件:藤条扩香瓶/云朵陶瓷扩香瓶/无火藤条套装/车载出风口香薰/玫瑰石膏扩香石)、香薰炉(4件:黑陶香薰炉/电子熏香炉/铜制雕花香炉/玻璃烛台香薰杯/恒温香薰机)。价格从 ¥29 到 ¥268,覆盖平价到高端。

瓶器Tab

5.5 社区 Tab(CommunityContent)

社区是香友圈,承担"浏览香评→评分点亮→点赞"路径。页面结构:

  1. 香友圈标题:💬香友圈 + 真实调配·有香必晒标签
  2. 香评列表(10 条):每条含头像+用户名+调配了「配方名」+ 评分星星(ratingStars 纯函数,琥珀金,评分点亮动画 opacity 0.15→1,500ms EaseOut)+ 香评文字 + 气味描述标签(紫罗兰背景#3A2650)+ 爱心点赞 + 点赞数 + 已点亮评分/点击卡片点亮评分提示
  3. 到底提示

评分点亮动画是社区Tab的特色交互:每条香评卡片点击时 toggleReveal 纯函数切换点亮状态,评分星星 opacity 根据 isRevealed 判断在 0.15 和 1.0 之间渐变,500ms EaseOut 动画。未点亮时星星几乎透明(opacity 0.15),如同未点燃的香薰灯;点击卡片后星星渐变为完全不透明(opacity 1),如同点亮一盏香氛灯。底部提示文字也相应变化(“点击卡片点亮评分"→"已点亮评分”)。这种点亮动画增加了社区互动的神秘感和仪式感。

香评卡片设计:每条香评含头像(emoji 20vp,紫褐背景圆角18)+用户名+调配了「配方名」,评分星星(13vp琥珀金,点亮动画),香评文字(11vp浅紫白),气味描述标签(如"清新花果·留香6小时",紫罗兰背景#3A2650),爱心点赞+点赞数。卡片深紫背景#2A1A40,14vp圆角。10条香评覆盖不同配方和评分(3-5星),真实感强。

社区Tab

5.6 我的 Tab(MineContent)

我的是个人中心,承担"资料卡→香型偏好→调香课→我的调香→购买记录"路径。页面结构:

  1. 资料卡:头像(🧑‍🎤34vp)+昵称(香友_8023)+调香学徒Lv.6·已调配32次 + 黑金会员标签(琥珀金背景#3A2A10)+ 配方12/关注48/粉丝96 三项统计
  2. 香型偏好卡:💗我的香型偏好 + 5维条形图(profileColor 纯函数,花香粉/木褐/果金/草绿/香料橙,花香90最高/香料38最低)
  3. 调香课卡:📚香氛课堂·调香大师课 + 99元起 + 首席调香师亲授·每周六徐汇实验室开课·含材料包 + 预约调香课按钮
  4. 我的调香标题(8支)+ 列表:每支含emoji+名称+香调标签+滴数+日期
  5. 购买记录标题(6笔)+ 列表:每笔含📦+精油名+容量×数量+价格+日期
  6. 底部标语:— 多多香薰调香 · 让香气成为签名 —

香型偏好可视化是我的Tab的核心数据可视化:5维香型偏好条形图,每维含香调名称(30vp宽)+ 进度条(背景紫褐#33224A,前景profileColor纯函数,宽度score%)+ 分数(26vp宽右对齐)。花香90(粉#F48FB1)最高,木香74(褐#A1887F),果香66(金#FFD54F),草本52(绿#81C784),香料38(橙#FF8A65)最低。5色条形图直观展示用户的香型偏好画像,如同调香师的嗅觉档案。

调香课预约:点击预约调香课触发预约弹框,含课程类型chips(入门99/进阶199/专题328,coursePrice 纯函数)+ 上课时间(2026-09-06周六·单次课90分钟)+ 4时段chips(周六14:00/周六19:00/周日10:00/周日15:00)+ 地点讲师信息卡(📍多多香薰实验室·徐汇店,👨‍🏫首席调香师·沈墨)+ 费用(coursePrice 纯函数)+ 课程描述(courseDesc 纯函数)+ 立即预约按钮。底部抽屉式弹框。

我的Tab


第六章 五大弹框组件设计

弹框是多多香薰调香交互体系的核心组成部分。项目共设计了 5 套弹框组件,覆盖购买精油、保存调香、编辑配方、删除配方确认、预约调香课五大业务场景。

6.1 购买精油弹框(buyModal)

定位:精油购买入口,容量+数量stepper+合计设计。

核心功能

  1. 精油回显:emoji(44vp)+名称+拉丁学名+纯度+科属·香调
  2. 容量选择(3 选 1):5ml/10ml/30ml,选中琥珀金背景深紫字,volumePrice 纯函数计算价格(5ml×0.6/10ml原价/30ml×2.6)
  3. 购买数量:−/+ stepper(1-20瓶),−紫褐背景/+琥珀金背景
  4. 合计:volumePrice×数量,琥珀金20vp加粗
  5. 单价说明:单价¥X/Xml · 30ml装更划算
  6. 立即购买按钮:琥珀金背景深紫字,点击关闭弹框

容量选择是购买弹框的特色,3种容量对应不同价格(5ml适合试用/10ml标准装/30ml大容量更划算),volumePrice 纯函数动态计算价格。数量 stepper 边界 1-20 瓶,合计金额实时计算。

6.2 保存调香弹框(saveModal)

定位:调香配方保存入口,名称+香调类型+描述+可见范围设计。

核心功能

  1. 配方名称:TextInput 输入(为你的香气起个名字)
  2. 香调类型(4 选 1):花香/木香/果香/东方香,选中紫罗兰背景深紫字
  3. 香调描述:TextArea 多行输入(记录这支香的故事与适合的场景…)
  4. 可见范围(2 选 1):公开/私密,选中琥珀金背景深紫字
  5. 取消/保存按钮:取消紫边/保存琥珀金,点击关闭弹框

保存调香弹框采用居中卡片式设计(86%宽度,18vp圆角),与其他弹框的底部抽屉式形成差异化。香调类型和可见范围 chips 选择增加便捷性,香调描述 TextArea 允许自由记录调香故事。

6.3 编辑配方弹框(editModal)

定位:配方编辑入口,名称+精油滴数+难度+备注设计。

核心功能

  1. 配方名称:TextInput 输入(回显原配方名)
  2. 精油滴数微调:每支精油 −/+ stepper(withOilDrops 纯函数,边界0-15),滴数琥珀金13vp加粗
  3. 难度(3 选 1):入门/进阶/大师,选中琥珀金背景深紫字
  4. 备注:TextArea 多行输入(记录调香心得…)
  5. 取消/保存修改按钮:取消紫边/保存修改琥珀金,withRecipeSaved 纯函数保存

编辑配方弹框采用底部抽屉式设计,精油滴数微调是核心功能,每支精油可独立调整滴数(边界0-15),withOilDrops 纯函数确保滴数不越界。难度选择允许重新定义配方难度等级。

6.4 删除配方确认弹框(deleteModal)

定位:配方删除的二次确认,居中警示式设计。

核心设计——橙色警告:⚠️图标(橙红#FF7043)+ 删除配方标题(橙红16vp加粗)+ 配方名称回显(「配方名」)+ 警告说明(删除后无法恢复,配方将移出配方库。)+ 取消/确认删除按钮(取消紫边/确认删除橙红背景,withoutRecipe 纯函数删除)。

删除配方确认弹框采用居中警示式设计(78%宽度,18vp圆角),⚠️警告图标增加警示感,橙红色调传递危险信号,警告说明增加不可恢复的透明度。居中布局让用户注意力集中在确认操作上,避免误删。

6.5 预约调香课弹框(courseModal)

定位:调香课预约入口,课程类型+时间+地点+费用设计。

核心功能

  1. 课程类型(3 选 1):入门/进阶/专题,选中琥珀金背景深紫字,coursePrice 纯函数对应价格(入门99/进阶199/专题328)
  2. 上课时间:2026-09-06(周六)· 单次课 90 分钟
  3. 时段选择(4 选 1):周六14:00/周六19:00/周日10:00/周日15:00,选中紫罗兰背景深紫字
  4. 地点讲师信息卡:📍上课地点·多多香薰实验室·徐汇店 + 👨‍🏫授课讲师·首席调香师·沈墨(紫褐背景#33224A,10vp圆角)
  5. 课程费用:coursePrice 纯函数,琥珀金20vp加粗
  6. 课程描述:courseDesc 纯函数(入门:从认识前中后三调开始,带走第一支亲手调配的10ml香水;进阶:香调结构与配比进阶,含3支成品与气味档案建立;专题:东方香调/天然香水专题,大师配方逐层拆解)
  7. 立即预约按钮:琥珀金背景深紫字,点击关闭弹框

预约调香课弹框采用底部抽屉式设计,课程类型和时段选择是核心功能,coursePrice 和 courseDesc 纯函数根据课程类型动态计算价格和描述。地点讲师信息卡增加课程的专业感和真实感。


第七章 交互动画与用户体验

7.1 ArkTS 交互动画机制

ArkTS 通过属性动画(.animation())实现交互动画。多多香薰调香主要使用属性动画(.animation()),在状态变化时自动应用过渡动画:

.scale({ x: this.activeTab === tab ? 1.15 : 1, y: this.activeTab === tab ? 1.15 : 1 })
.animation({ duration: 200, curve: Curve.EaseOut })

属性动画绑定在组件属性上,当依赖的状态变化时自动触发动画过渡,无需手动管理动画帧。本应用 7 组交互动画全部使用属性动画方式。

7.2 七组交互动画详解

动画 组件 属性 时长 曲线 效果
Tab图标缩放 底部Tab scale 1.0↔1.15 200ms EaseOut 激活时放大1.15
精油按压缩放 精油卡片 scale 1.0↔0.96 120ms EaseOut 按压时缩小0.96
调香滴数跳动 调香滴数 scale 1.0↔1.45 180ms EaseOut 调整时放大1.45
比例条动画 调香比例条 width 动态 350ms EaseOut 滴数变化时平滑过渡
强度条动画 调香强度条 width 动态 350ms EaseOut 总滴数变化时平滑过渡
配方点赞缩放 配方爱心 scale 1.0↔1.25 200ms EaseOut 点赞时放大1.25
社区评分点亮 社区评分星星 opacity 0.15↔1 500ms EaseOut 点击卡片点亮评分

Tab图标缩放动画:底部Tab切换时,激活图标 scale 从 1.0 到 1.15,200ms EaseOut 动画。激活时图标微弹放大,配合琥珀金文字颜色和底部分割线,形成三重选中反馈。1.15倍微放大增加Tab切换的弹性反馈。

精油按压缩放动画:精油卡片 onTouch 事件监听 Down/Up,Down 时 pressedId=o.id,Up 时 pressedId=-1,scale 根据 pressedId===o.id 在 0.96 和 1.0 之间切换,120ms EaseOut 动画。120ms 较短时长模拟快速按压,如同按压精油瓶的真实感。每张精油卡片都有此动画。

调香滴数跳动动画:点击 +/− 调整滴数时,bumpId=o.id,bumpTick=!bumpTick,滴数数字 scale 根据 bumpId===o.id && bumpTick 在 1.45 和 1.0 之间切换,180ms EaseOut 动画。1.45倍较大放大模拟滴管滴下精油的跳动感,是调香工作台的特色交互。每次调整滴数时数字跳动,增加手作调香的真实感和趣味性。

比例条动画:滴数变化时,前中后调比例条宽度(noteRatioW 纯函数)平滑过渡,350ms EaseOut 动画。350ms 较长时长让比例变化更加平滑自然,如同香调比例在调香瓶中缓缓融合。三条比例条(前调金/中调紫/后调橙)同时动画,视觉效果丰富。

强度条动画:总滴数变化时,强度条宽度(strengthBarW 纯函数)平滑过渡,350ms EaseOut 动画。强度条颜色(strengthColor 纯函数)根据总滴数动态变化(淡雅紫/均衡金/浓郁橙/强烈红),强度等级(strengthLevel 纯函数)实时更新。强度条动画如同调香实验室的强度检测仪,实时反馈香气浓度变化。

配方点赞缩放动画:配方卡片爱心点击时 withRecipeLoved 纯函数切换点赞状态,爱心 scale 从 1.0 到 1.25,200ms EaseOut 动画。点赞时爱心从白色🤍变为红色❤️并放大1.25倍,点赞数+1。1.25倍放大增加点赞的满足感,200ms EaseOut 曲线模拟点赞时快速放大、释放时缓慢恢复的弹性效果。

社区评分点亮动画:香评卡片点击时 toggleReveal 纯函数切换点亮状态,评分星星 opacity 根据 isRevealed 判断在 0.15 和 1.0 之间渐变,500ms EaseOut 动画。500ms 较长时长让点亮过程更加缓慢神秘,如同点亮一盏香氛灯的仪式感。未点亮时星星几乎透明(opacity 0.15),点击后渐变为完全不透明(opacity 1),底部提示文字也相应变化。这种点亮动画是社区Tab的特色交互,增加神秘感和仪式感。

7.3 属性动画的优势

传统逐帧动画需要手动管理动画帧和定时器,属性动画通过框架自动调度:状态变化时自动触发过渡、动画参数(时长/曲线)声明式配置、多个属性动画可同时运行互不干扰、组件销毁时动画自动停止。多多香薰调香 7 组交互动画全部采用属性动画方式,代码简洁且性能优良。

7.4 交互反馈设计

项目通过多种方式提供即时交互反馈:

  • Tab切换:图标缩放+文字颜色+底部分割线三重反馈
  • 卡片点击:按压缩放动画提供真实按压反馈
  • 滴数调整:滴数跳动动画+比例条动画+强度条动画三重联动反馈
  • 点赞:爱心颜色变化(🤍→❤️)+缩放动画+点赞数变化三重反馈
  • 评分点亮:星星opacity渐变+提示文字变化双重反馈
  • chips选择:点击后立即切换选中态,颜色变化(琥珀金/紫罗兰背景)
  • stepper:−/+ 点击后数字立即变化,边界值自动限制(滴数0-20/数量1-20)
  • 删除警告:⚠️图标+橙红色调+警告说明+确认按钮橙红背景,增加操作谨慎性

这些即时反馈确保用户操作的"确定性",提升交互流畅度和调香体验。滴数跳动+比例条+强度条的三重联动是本应用的特色交互,每次调整滴数时三个动画同时触发,如同真实调香实验室的多仪器联动检测,增加手作调香的沉浸感。


第八章 精油与调香系统深度解析

8.1 精油数据体系

项目定义 18 支精油(OILS 常量),每支包含:id、name、botanical(拉丁学名)、note(前调/中调/后调)、family(科属:花香/果香/木香/草本/树脂/香料)、price(10ml标准价)、volume(标准容量10ml)、purity(纯度百分比)、icon(emoji)、hot(是否热卖)。

18 支精油覆盖 6 大科属和 3 大香调:

  • 前调(7支):甜橙¥45、佛手柑¥88、欧薄荷¥38、澳洲茶树¥52、粉红葡萄柚¥48、迷迭香¥42、甜茴香¥62
  • 中调(6支):薰衣草¥68、大马士革玫瑰¥328、茉莉原精¥298、依兰依兰¥128、波旁天竺葵¥85、罗马洋甘菊¥108
  • 后调(5支):大西洋雪松¥72、印度檀香¥198、岩兰草¥118、索马里乳香¥158、广藿香¥138

价格区间 ¥38-¥328,覆盖平价到高端。大马士革玫瑰¥328(5ml)和茉莉原精¥298(5ml)是高端精油,纯度99.8%和99.7%。甜橙¥45和欧薄荷¥38是入门平价精油。热卖标签(hot=true)的精油有7支:薰衣草、甜橙、佛手柑、大马士革玫瑰、印度檀香、粉红葡萄柚。

8.2 香调筛选与双列宫格

精油Tab通过香调筛选 chips(全调/前调/中调/后调)切换 curNote 状态,oilsByNote 纯函数按香调筛选精油。选中态琥珀金背景深紫字,未选中紫褐背景浅紫白文字。

双列宫格使用 Flex({wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween}) 实现,每卡宽度 48.5%,自动换行。每卡包含:emoji(28vp)+香调标签(noteColor 纯函数,前调金#FFD54F/中调紫#B388FF/后调橙#FF8A65,半透明背景)+科属+HOT标签(橙红边框),下方精油名称(13vp)+拉丁学名(8vp,1行省略),分割线,价格(琥珀金15vp加粗)+容量+购买按钮。

卡片边框使用香调色半透明(noteColor(o.note)+‘66’),1.5vp宽度,不同香调的精油卡片有不同颜色边框,直观区分前中后调。按压缩放动画(scale 0.96,120ms EaseOut)增加按压反馈。

8.3 调香工作台数据体系

项目定义 6 支已选精油(BLEND_OILS 常量),每支包含:id、oilName、note(前调/中调/后调)、drops(滴数)、icon。

6 支已选精油构成一个完整配方:

  • 前调(2支,7滴):甜橙4滴、佛手柑3滴
  • 中调(2支,7滴):薰衣草5滴、茉莉原精2滴
  • 后调(2支,5滴):大西洋雪松3滴、印度檀香2滴

总滴数 19 滴,属于"均衡"强度等级(strengthLevel 纯函数:<20滴=均衡)。前调比例 36.8%、中调比例 36.8%、后调比例 26.3%,是一个前中调均衡、后调打底的经典配方结构。

8.4 滴数微调与比例条联动

调香工作台的核心交互是滴数微调与比例条、强度条的三重联动:

滴数微调:每支精油有 −/+ 按钮,点击时 withBlendDrops 纯函数更新滴数(边界0-20滴),bumpId 和 bumpTick 状态触发滴数跳动动画(scale 1.45,180ms EaseOut)。每次调整滴数时数字放大跳动,如同滴管滴下精油的真实感。

比例条联动:滴数变化时,dropsOfNote 纯函数重新统计前中后调各调总滴数,noteRatioW 纯函数计算各调比例百分比,比例条宽度平滑过渡(350ms EaseOut动画)。三条比例条(前调金/中调紫/后调橙)同时更新,直观展示香调结构变化。

强度条联动:滴数变化时,totalDrops 纯函数重新统计总滴数,strengthLevel 纯函数计算强度等级(空瓶/淡雅/均衡/浓郁/强烈),strengthColor 纯函数计算强度颜色(淡雅紫/均衡金/浓郁橙/强烈红),strengthBarW 纯函数计算强度条宽度(total/40×100%,上限100%),强度条宽度平滑过渡(350ms EaseOut动画)。强度计如同调香实验室的强度检测仪,实时反馈香气浓度变化。

三重联动让每次滴数调整都有丰富的视觉反馈,增加手作调香的沉浸感和趣味性。用户可以直观看到调整某支精油滴数对整体香调结构和香气强度的影响,如同真实调香师在实验室中调配香水。

8.5 容量定价策略

购买精油弹框提供 3 种容量选择,volumePrice 纯函数根据容量计算价格:

  • 5ml:price × 0.6(试用装,适合初次尝试高价精油)
  • 10ml:price × 1.0(标准装,默认选择)
  • 30ml:price × 2.6(大容量装,单位价格更划算)

容量定价策略鼓励用户购买大容量装(30ml单位价格仅为10ml的87%),同时提供5ml试用装降低高价精油(如大马士革玫瑰¥328)的入门门槛。弹框提示"30ml装更划算",引导用户选择大容量。


第九章 配方与瓶器系统深度解析

9.1 配方数据体系

项目定义 12 个配方(RECIPES 常量),每个包含:id、name、creator(创作者)、profile(香调类型)、notes(前中后调标签数组)、oils(精油滴数数组 OilDrop[])、difficulty(入门/进阶/大师)、likes(点赞数)、loved(是否已收藏)、popularity(人气百分比)。

12 个配方覆盖 4 大香调类型和 3 个难度等级:

  • 花香(4个):仲夏夜之梦(入门,486赞)、晨光玫瑰(进阶,521赞)、白茶时光(进阶,375赞)、初恋柠檬(入门,267赞)
  • 木香(4个):雨后森林(进阶,352赞)、深夜书房(入门,289赞)、乌木沉香(大师,458赞)、禅意庭园(进阶,341赞)
  • 果香(3个):海盐橙花(进阶,433赞)、薄荷清晨(入门,198赞)、蜜桃乌龙(入门,302赞)
  • 东方香(1个):东方迷雾(大师,612赞,最高人气99%)

难度分布:入门5个、进阶5个、大师2个。东方迷雾(大师,612赞,人气99%)是最受欢迎的配方,乌木沉香(大师,458赞,人气88%)次之。

9.2 配方结构设计

每个配方包含完整的前中后调结构:

  • notes 数组:前中后调标签,如[“前调·果香”, “中调·花香”, “后调·木香”],紫罗兰背景#3A2650
  • oils 数组:精油滴数,如[{name:“甜橙”,drops:4},{name:“薰衣草”,drops:5},{name:“大西洋雪松”,drops:3}],紫褐背景#33224A
  • difficulty 标签:入门紫#9D6DD1/进阶金#FFB300/大师红#FF7043,半透明背景
  • 人气条:琥珀金填充,popularity%宽度,紫褐背景
  • 点赞收藏:爱心(🤍/❤️)+点赞数,点赞缩放动画

配方结构如同调香师的配方笔记,专业且易读。前中后调标签展示香调结构,精油滴数展示具体配比,难度标签提示入门门槛,人气条展示受欢迎程度。

9.3 配方编辑与删除

配方Tab提供编辑和删除功能:

编辑配方:点击编辑按钮触发编辑弹框,openEdit 方法初始化编辑状态(editId/editName/editOils/editDifficulty/editNote)。编辑弹框含配方名称输入(回显原配方名)+ 精油滴数微调(每支−/+,withOilDrops 纯函数,边界0-15)+ 难度chips(入门/进阶/大师)+ 备注TextArea + 取消/保存修改按钮。保存时 withRecipeSaved 纯函数更新配方(名称非空则更新,否则保留原名;精油滴数和难度直接更新)。

删除配方:点击删除按钮触发删除确认弹框,deleteTarget 状态记录待删除配方。删除确认弹框含⚠️警告图标(橙红)+ 删除配方标题 + 配方名称回显 + 警告说明(删除后无法恢复,配方将移出配方库)+ 取消/确认删除按钮。确认删除时 withoutRecipe 纯函数过滤删除配方(返回id不匹配的配方数组)。

编辑和删除功能让配方库可维护,用户可以根据个人嗅觉记忆微调配方滴数,或删除不再需要的配方。withRecipeSaved 和 withoutRecipe 纯函数采用不可变更新方式(返回新数组而非修改原数组),符合 ArkTS 状态管理最佳实践。

9.4 瓶器数据体系

项目定义 15 件瓶器(VESSELS 常量),每件包含:id、name、type(香水瓶/扩香瓶/香薰炉)、material(水晶/玻璃/合金/陶瓷/石膏/黄铜/ABS)、capacity(容量)、price、icon。

15 件瓶器分为 3 大类:

  • 香水瓶(6件):琥珀水晶瓶¥128(水晶10ml)、磨砂滚珠瓶¥45(玻璃10ml)、鎏金细颈瓶¥88(玻璃30ml)、手工吹制圆瓶¥168(玻璃50ml)、复古喷泵旅行瓶¥58(合金8ml)
  • 扩香瓶(5件):藤条扩香瓶¥78(玻璃120ml)、云朵陶瓷扩香瓶¥118(陶瓷150ml)、无火藤条套装¥98(玻璃200ml)、车载出风口香薰¥39(合金5ml)、玫瑰石膏扩香石¥29(石膏50g)
  • 香薰炉(4件):黑陶香薰炉¥138(陶瓷)、电子熏香炉¥198(陶瓷USB款)、铜制雕花香炉¥268(黄铜)、玻璃烛台香薰杯¥68(玻璃)、恒温香薰机¥158(ABS300ml)

价格区间 ¥29-¥268,覆盖平价到高端。玫瑰石膏扩香石¥29是最平价的瓶器,铜制雕花香炉¥268是最高端的。瓶器类型多样,满足不同使用场景(随身/居家/车载/礼赠)。

9.5 三列紧凑宫格设计

瓶器Tab使用三列紧凑宫格展示15件瓶器,Flex({wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween}) 实现,每卡宽度 31.8%,自动换行。每卡居中对齐,包含:emoji(30vp)+名称(11vp,1行省略)+类型标签(9vp紫罗兰背景#3A2650)+材质·容量(8vp,1行省略)+价格(14vp琥珀金加粗)。

三列紧凑布局最大化展示瓶器数量,如同电商商品列表。卡片深紫背景#2A1A40,12vp圆角,10vp内边距,10vp顶部间距。紧凑但不拥挤,每件瓶器的核心信息(名称/类型/材质/容量/价格)一目了然。


第十章 社区与我的系统深度解析

10.1 香评数据体系

项目定义 10 条香评(REVIEWS 常量),每条包含:id、user(用户名)、avatar(emoji)、blendName(调配的配方名)、rating(评分1-5星)、text(香评文字)、scentDesc(气味描述)、likes(点赞数)、loved(是否已点赞)。

10 条香评覆盖不同配方和评分:

  • 5星(7条):仲夏夜之梦(128赞)、深夜书房(96赞)、东方迷雾(210赞)、雨后森林(158赞)、乌木沉香(187赞)、白茶时光(88赞)、晨光玫瑰(245赞,最高赞)
  • 4星(2条):蜜桃乌龙(75赞)、薄荷清晨(64赞)
  • 3星(1条):初恋柠檬(42赞,唯一差评)

香评文字真实感强,如"前调甜橙一打开就是夏天,中调薰衣草很温柔,尾调雪松沉稳留香整晚"、“广藿香配依兰,神秘感拉满,朋友都问我在哪买的”、“玫瑰贵有贵的道理,一滴就是一整座花园”。气味描述标签包含留香时长(3-12小时),如"清新花果·留香6小时"、“神秘东方·留香10小时”、“醇厚树脂·留香12小时”。

10.2 评分点亮交互

社区Tab的特色交互是评分点亮动画:

初始状态:每条香评的评分星星(ratingStars 纯函数,琥珀金#FFB300)opacity 为 0.15(几乎透明),如同未点燃的香薰灯。底部提示"点击卡片点亮评分"。第一条香评(id=1)默认已点亮(revealedIds=[1]),星星 opacity 为 1。

点亮过程:点击香评卡片时,toggleReveal 纯函数切换点亮状态(已点亮则移除,未点亮则添加),isRevealed 纯函数判断当前状态。评分星星 opacity 根据 isRevealed 在 0.15 和 1.0 之间渐变,500ms EaseOut 动画。500ms 较长时长让点亮过程更加缓慢神秘,如同点亮一盏香薰灯的仪式感。底部提示文字相应变化(“点击卡片点亮评分"→"已点亮评分”)。

交互意义:评分点亮交互增加了社区互动的神秘感和仪式感。用户需要主动点击卡片才能看到完整评分,如同揭开香评的神秘面纱。点亮过程的缓慢渐变动画(500ms EaseOut)增加了仪式感,让每条香评的评分展示都成为一个小仪式。已点亮的香评星星完全不透明,未点亮的几乎透明,视觉对比强烈。

10.3 香评卡片设计

每条香评卡片包含:头像(emoji 20vp,紫褐背景圆角18)+用户名+调配了「配方名」,评分星星(13vp琥珀金,点亮动画),香评文字(11vp浅紫白),气味描述标签(如"清新花果·留香6小时",紫罗兰背景#3A2650),爱心点赞+点赞数。卡片深紫背景#2A1A40,14vp圆角,12vp内边距,10vp顶部间距。

香评卡片设计简洁专业,头像+用户名+配方名展示香评来源,评分星星(点亮动画)是视觉焦点,香评文字是核心内容,气味描述标签增加专业感,爱心点赞增加互动性。点击卡片触发评分点亮,点击爱心触发点赞(当前为静态展示,可扩展为 withReviewLiked 纯函数)。

10.4 个人资料与统计

我的Tab顶部是资料卡,包含:头像(🧑‍🎤34vp,紫褐背景圆角30)+昵称(香友_8023,16vp加粗)+调香学徒Lv.6·已调配32次(10vp)+黑金会员标签(琥珀金背景#3A2A10,9vp)+ 三项统计(配方12琥珀金/关注48紫罗兰/粉丝96浅紫白,每项含数字15vp加粗+标签9vp)。

资料卡设计如同调香师的名片,昵称+等级+会员标签展示身份,三项统计(配方/关注/粉丝)展示社交数据。黑金会员标签增加尊贵感,调香学徒Lv.6等级增加游戏化成长感。

10.5 香型偏好可视化

香型偏好卡是我的Tab的核心数据可视化:5维香型偏好条形图,每维含香调名称(30vp宽)+ 进度条(背景紫褐#33224A,前景profileColor纯函数,宽度score%)+ 分数(26vp宽右对齐)。

5维香型偏好:

  • 花香:90分(粉#F48FB1),最高偏好
  • 木香:74分(褐#A1887F)
  • 果香:66分(金#FFD54F)
  • 草本:52分(绿#81C784)
  • 香料:38分(橙#FF8A65),最低偏好

profileColor 纯函数根据香调返回对应颜色(花香粉/木褐/果金/草绿/香料橙),5色条形图直观展示用户的香型偏好画像。花香90分最高,说明用户偏爱花香调;香料38分最低,说明用户较少使用香料调。香型偏好画像如同调香师的嗅觉档案,帮助用户了解自己的嗅觉偏好,也可用于配方推荐(推荐高偏好香调的配方)。

10.6 我的调香与购买记录

我的调香(8支):每支含emoji+名称+香调标签(紫罗兰背景#3A2650)+滴数+日期。8支调香覆盖不同香调:书房晚钟(木香12滴)、周一提神(草本8滴)、桃气乌龙(果香15滴)、睡前雾语(花香10滴)、雨天松针(木香11滴)、甜梦泡泡(果香9滴)、冥想檀境(东方香13滴)、初试调香(花香6滴)。我的调香如同个人调香作品集,记录用户的调香历程。

购买记录(6笔):每笔含📦+精油名+容量×数量+价格+日期。6笔购买记录覆盖不同精油和瓶器:大马士革玫瑰5ml¥328、印度檀香10ml×2¥396、薰衣草10ml¥68、佛手柑10ml×3¥264、藤条扩香瓶120ml¥78、甜橙10ml×2¥90。购买记录如同个人消费档案,帮助用户了解自己的精油和瓶器购买历史。

10.7 调香课预约

调香课卡是我的Tab的特色功能:📚香氛课堂·调香大师课 + 99元起 + 首席调香师亲授·每周六徐汇实验室开课·含材料包 + 预约调香课按钮。

点击预约调香课触发预约弹框,含:

  • 课程类型(3选1):入门¥99/进阶¥199/专题¥328,coursePrice 纯函数对应价格
  • 上课时间:2026-09-06(周六)·单次课90分钟
  • 时段选择(4选1):周六14:00/周六19:00/周日10:00/周日15:00
  • 地点讲师信息卡:📍多多香薰实验室·徐汇店 + 👨‍🏫首席调香师·沈墨(紫褐背景#33224A)
  • 课程费用:coursePrice 纯函数,琥珀金20vp加粗
  • 课程描述:courseDesc 纯函数(入门:从认识前中后三调开始,带走第一支亲手调配的10ml香水;进阶:香调结构与配比进阶,含3支成品与气味档案建立;专题:东方香调/天然香水专题,大师配方逐层拆解)
  • 立即预约按钮:琥珀金背景深紫字

调香课预约功能连接了线上DIY调香和线下专业课程,形成"线上自学+线下进阶"的完整学习路径。coursePrice 和 courseDesc 纯函数根据课程类型动态计算价格和描述,信息卡增加课程的专业感和真实感。


第十一章 性能优化与工程实践

11.1 单文件架构的性能考量

多多香薰调香采用单文件架构(约 1400 行),所有组件、数据、函数在一个 .ets 文件中。单文件架构的性能优势:无跨文件导入开销、编译时类型检查更高效、运行时模块加载更快。对于中等规模应用(1400行以内),单文件架构是合理选择。

11.2 @State 状态最小化原则

项目遵循"状态最小化"原则:

  • 全局数据用 const 常量(OILS/BLEND_OILS/RECIPES/VESSELS/REVIEWS/MY_BLENDS/PURCHASES/SCENT_PROFILE 等),不进入响应式系统
  • 组件内交互状态用 @State(curNote/pressedId/showBuy/buyVol/buyQty/blendOils/bumpId/bumpTick/recipes/revealedIds/showCourse 等),仅在需要时声明
  • 根组件仅管理 activeTab 一个跨组件状态

状态最小化减少响应式系统的依赖追踪开销,提升渲染性能。每个 @State 变化时仅触发依赖该状态的组件重新渲染,不影响其他组件。blendOils 状态在滴数调整时更新,触发比例条和强度条重新渲染;recipes 状态在点赞/编辑/删除时更新,触发配方列表重新渲染;revealedIds 状态在点亮评分时更新,触发香评列表重新渲染。

11.3 ForEach 键值优化

项目所有 ForEach 均提供键值生成函数(第二个参数),如 (o: OilItem) => o.id.toString()(r: RecipeItem) => r.id.toString()(rv: ReviewItem) => rv.id.toString()。键值生成函数确保列表项在数据变化时被正确复用,而非全量重建,大幅提升列表渲染性能。

ForEach 键值优化在列表数据变化时(如配方点赞/编辑/删除、社区评分点亮)尤为重要:框架通过键值识别哪些项变化、哪些项不变,仅重新渲染变化的项,不变的项直接复用。精油列表(18支)、配方列表(12个)、香评列表(10条)、我的调香(8支)、购买记录(6笔)均使用键值优化。

11.4 纯函数数据转换

项目 23 个全局纯函数处理数据转换(noteColor/oilsByNote/volumePrice/dropsOfNote/totalDrops/noteRatioW/withBlendDrops/strengthLevel/strengthColor/strengthBarW/difficultyColor/withRecipeLoved/withoutRecipe/withOilDrops/withRecipeSaved/ratingStars/isRevealed/toggleReveal/profileColor/coursePrice/courseDesc 等),纯函数相同输入永远产生相同输出,无副作用。纯函数的性能优势:可缓存结果、易于测试、无状态依赖、可并行执行。

withBlendDrops、withOilDrops、withRecipeLoved、withoutRecipe、withRecipeSaved、toggleReveal 纯函数采用不可变更新方式(返回新数组/新对象而非修改原数组),符合 ArkTS 状态管理最佳实践,避免直接修改 @State 导致的状态不一致。不可变更新方式让状态变化可追踪、可调试、可回滚。

11.5 属性动画性能

项目 7 组交互动画全部使用属性动画(.animation()),属性动画由框架自动调度,性能优于手动逐帧动画。属性动画仅在依赖的状态变化时触发,不持续占用 CPU。动画时长控制在 120-500ms,符合移动端交互动画的最佳时长范围(200-500ms,按压反馈可更短120ms)。

Tab图标缩放 200ms(适中选中反馈)、精油按压缩放 120ms(快速按压反馈)、调香滴数跳动 180ms(适中跳动反馈)、比例条动画 350ms(平滑比例过渡)、强度条动画 350ms(平滑强度过渡)、配方点赞缩放 200ms(适中点赞反馈)、社区评分点亮 500ms(缓慢点亮仪式),不同动画时长匹配不同交互场景。

11.6 Flex 布局性能

项目大量使用 Flex 布局(双列宫格 FlexWrap.Wrap、三列宫格 FlexWrap.Wrap、Tab栏 FlexAlign.Center、统计栏 FlexAlign.SpaceBetween),Flex 布局是 ArkTS 推荐的高性能布局方式,框架对 Flex 布局有专门优化。避免使用复杂的 position 绝对定位(仅在弹框和角标等必要场景使用),减少布局计算开销。

11.7 图片占位与懒加载

社区香评和我的调香使用 emoji 占位(非真实图片加载),避免网络图片加载的性能开销。对于概念应用,emoji 占位是合理选择,既展示布局又保证性能。实际生产环境可替换为 LazyForEach + 网络图片懒加载。

11.8 代码组织规范

项目代码组织遵循以下规范:

  • 接口定义在前(11 个 interface)
  • 调色板常量次之(SP)
  • 静态选项数组再次之(5 组 const)
  • 纯函数再次之(23 个 function)
  • 业务数据常量再次之(8 组 const)
  • Tab 枚举次之(ScentTab)
  • 根入口组件最后(ScentApp @Entry)
  • 子组件按 Tab 顺序排列(OilContent→BlendContent→RecipeContent→VesselContent→CommunityContent→MineContent)

这种"定义→配置→函数→数据→组件"的组织方式符合阅读习惯,便于维护和扩展。


第十二章 香薰调香文化与DIY香水趋势

12.1 香薰调香的兴起

香薰调香是近年来美妆圈的新兴品类,它将香水从"专柜奢侈品"转变为"DIY手作体验"。香薰调香的核心魅力:亲手调配专属香气、了解前中后调结构、探索精油的嗅觉记忆、创造独一无二的个人签名香。Z世代是香薰调香的核心消费群体,她们追求"个性化"和"手作体验",香薰调香的DIY属性完美契合这一需求。多多香薰调香正是瞄准这一市场,以拼多多式高性价比电商模式提供精油选购、调香工作台、配方库、瓶器器具、香友社区的完整DIY调香体验。

12.2 前中后调结构

香水的经典结构是前调(Top Note)、中调(Heart Note)、后调(Base Note)三层结构:

  • 前调:香水喷洒后最先闻到的香气,持续时间短(15分钟-2小时),通常是清新的柑橘、草本、果香,给人第一印象
  • 中调:前调消散后出现的核心香气,持续时间中等(2-6小时),通常是花香、辛香,是香水的灵魂和个性
  • 后调:中调消散后留下的基底香气,持续时间长(6-12小时或更久),通常是木香、树脂、麝香,给人深沉持久的余韵

多多香薰调香的调香工作台完整复现前中后调结构:6支已选精油分为前调(甜橙、佛手柑)、中调(薰衣草、茉莉原精)、后调(大西洋雪松、印度檀香),比例条实时展示前中后调各调比例,用户可以通过调整各调精油滴数来改变香调结构。前中后调结构是调香的基础理论,理解这一结构是成为调香师的第一步。

12.3 精油科属与香气特征

项目定义 18 支精油,覆盖 6 大科属,每科属有独特的香气特征:

  • 花香(薰衣草、大马士革玫瑰、茉莉原精、依兰依兰、波旁天竺葵、罗马洋甘菊):甜美、浪漫、女性化,是中调的核心
  • 果香(甜橙、佛手柑、粉红葡萄柚):清新、活泼、明亮,是前调的核心
  • 木香(大西洋雪松、印度檀香):沉稳、温暖、持久,是后调的核心
  • 草本(欧薄荷、澳洲茶树、迷迭香):清凉、提神、草本感,可作前调或中调
  • 树脂(索马里乳香):神圣、温暖、烟熏感,是后调的高级素材
  • 香料(甜茴香、广藿香):辛香、异域、持久,是东方香调的核心

每支精油都有拉丁学名(如 Lavandula angustifolia、Rosa damascena、Citrus bergamia)和纯度百分比(99.1%-99.8%),传递专业感。GC/MS检测和冷压蒸馏标签增加精油的品质保证。

12.4 配方库与调香学习

配方库是调香学习的核心资源,12个配方覆盖4大香调类型和3个难度等级:

  • 入门配方(5个):仲夏夜之梦、深夜书房、薄荷清晨、初恋柠檬、蜜桃乌龙,精油种类少(2-3种),滴数简单,适合新手
  • 进阶配方(5个):雨后森林、晨光玫瑰、海盐橙花、白茶时光、禅意庭园,精油种类中等(3-4种),香调结构更复杂,适合有一定基础的调香爱好者
  • 大师配方(2个):东方迷雾、乌木沉香,精油种类多(4种),包含稀有精油(广藿香、乳香、依兰依兰),香调结构复杂,适合高级调香爱好者

每个配方包含完整的前中后调结构和精油滴数,用户可以一键套用配方,也可以根据个人嗅觉记忆微调滴数(编辑配方功能)。配方库如同调香师的配方笔记,是学习调香的最佳教材。

12.5 瓶器器具与香气呈现

瓶器器具是香气呈现的载体,15件瓶器覆盖3大类:

  • 香水瓶(6件):用于盛装调配好的香水,从平价磨砂滚珠瓶(¥45)到高端手工吹制圆瓶(¥168),满足不同需求
  • 扩香瓶(5件):用于无火扩香,通过藤条、石膏、陶瓷等载体缓慢释放香气,适合居家环境
  • 香薰炉(4件):用于加热扩香,通过蜡烛、电子、恒温等方式加热精油释放香气,适合冥想、瑜伽等场景

瓶器器具的选择影响香气的呈现方式和持续时间:香水适合随身使用和精准喷洒,扩香瓶适合居家环境的持续扩香,香薰炉适合仪式感的香气体验。多多香薰调香提供丰富的瓶器选择,让用户可以根据使用场景选择合适的香气呈现方式。

12.6 香友社区与嗅觉记忆分享

香友社区是DIY调香的社交核心,10条香评覆盖不同配方和评分,用户可以分享自己的调香体验和嗅觉记忆。社区的价值:

  • 真实反馈:用户的真实香评比官方描述更有说服力,帮助其他用户了解配方的实际效果
  • 嗅觉记忆:每条香评都是一段嗅觉记忆的分享,如"前调甜橙一打开就是夏天"、“玫瑰贵有贵的道理,一滴就是一整座花园”
  • 互动交流:点赞、评论、收藏等互动功能增加社区活跃度(当前为静态展示,可扩展)
  • 评分点亮:点击卡片点亮评分的交互增加神秘感和仪式感

香友社区如同调香爱好者的嗅觉沙龙,大家在这里分享调香心得、交流嗅觉记忆、互相学习进步。评分点亮交互是社区的特色设计,增加了互动的神秘感和仪式感。

12.7 调香课与专业学习

调香课是连接线上DIY和线下专业学习的桥梁,3种课程类型:

  • 入门课(¥99):从认识前中后三调开始,带走第一支亲手调配的10ml香水,适合零基础新手
  • 进阶课(¥199):香调结构与配比进阶,含3支成品与气味档案建立,适合有一定基础的调香爱好者
  • 专题课(¥328):东方香调/天然香水专题,大师配方逐层拆解,适合高级调香爱好者

调香课由首席调香师沈墨亲授,每周六在徐汇实验室开课,单次课90分钟,含材料包。调香课提供专业的调香指导和实操体验,是线上自学的有益补充。线上DIY调香降低入门门槛,线下调香课提升专业水平,形成完整的学习路径。

12.8 拼多多式DIY香薰电商模式

多多香薰调香采用拼多多式DIY香薰电商模式,核心特征:

  • 高性价比:精油价格¥38-¥328,瓶器¥29-¥268,远低于专柜香水
  • 高折扣营销:满99包邮、限时折扣、新香首发、30ml装更划算、破损必赔、顺丰包邮
  • 高转化UI:琥珀金主按钮、价格加粗、热卖标签、容量定价策略、按压缩放反馈
  • DIY手作:调香工作台、配方库、瓶器器具,让用户亲手调配专属香水
  • 社区社交:香友圈、评分点亮、点赞收藏,增加用户粘性和社交传播
  • 游戏化运营:调香学徒等级、黑金会员、香型偏好画像、我的调香作品集

拼多多式DIY香薰电商模式通过极致性价比和DIY手作体验,在下沉市场和年轻消费者中获得成功。多多香薰调香将这一模式应用于香薰调香品类,降低了DIY调香的入门门槛,让更多人可以体验亲手调配香水的乐趣。


第十三章 DIY香水生活方式与嗅觉美学

13.1 香水作为个人签名

香水早已超越"香氛产品"的范畴,成为个人签名的表达。对于Z世代而言,香水是"个人风格"的重要组成部分,不同的香水搭配不同的服装、场合、心情。多多香薰调香的DIY调香模式让用户可以创造独一无二的个人签名香,而非使用大众香水。调香工作台的滴数微调让每一支香水都是独一无二的,如同个人的嗅觉签名。香型偏好画像(花香90/木香74/果香66/草本52/香料38)如同个人的嗅觉DNA,帮助用户了解自己的嗅觉偏好,创造更符合个人风格的香水。

13.2 薰衣夜深紫与琥珀金的色彩心理学

多多香薰调香的配色体系具有深层的色彩心理学考量:

  • 薰衣夜 #1E1033 深紫:神秘、沉浸、高级、夜晚,营造深夜调香实验室的氛围,深紫色调让用户进入专注的调香状态,如同在深夜的实验室中独自探索香气的奥秘
  • 琥珀金 #FFB300:温暖、奢华、行动召唤、烛光,作为主按钮和价格强调色,激发购买和创作冲动,琥珀金在深紫背景上如同实验台上的烛光,是深夜实验室的核心光源
  • 紫罗兰 #9D6DD1:优雅、神秘、香调分类,作为辅助强调色用于香调类型和标签,传递优雅和神秘感
  • 橙红 #FF7043:警告、热情、HOT,作为警告色和热卖标签,传递紧迫感和热情

深紫+琥珀金的配色组合如同深夜实验室的烛光,既神秘又温暖,既高级又亲切。深紫背景营造沉浸感,琥珀金强调行动和价值,紫罗兰增加优雅和神秘,橙红传递警告和热情。四色搭配层次分明,符合DIY调香的深夜实验室氛围。

13.3 滴数跳动与手作沉浸感

滴数跳动动画是多多香薰调香的特色交互,每次调整滴数时数字放大跳动(scale 1.45,180ms EaseOut),如同滴管滴下精油的真实感。这种微交互虽然微小,但对手作沉浸感有显著提升:

  • 真实感:滴数跳动模拟滴管滴下精油的动作,让用户感觉在真实地滴加精油
  • 节奏感:每次点击+/−都有跳动反馈,形成稳定的操作节奏
  • 满足感:数字跳动的视觉反馈带来轻微的愉悦感,增加调香的趣味性
  • 沉浸感:滴数跳动+比例条动画+强度条动画的三重联动,让每次调整都有丰富的视觉反馈,增加手作调香的沉浸感

滴数跳动动画是手作沉浸感的核心,让虚拟的调香工作台有了真实的触感反馈。配合比例条和强度条的联动动画,用户可以直观看到每次滴数调整对整体香调结构和香气强度的影响,如同真实调香师在实验室中观察香气的变化。

13.4 评分点亮与嗅觉仪式感

评分点亮动画是社区Tab的特色交互,点击香评卡片时评分星星 opacity 从 0.15 渐变到 1(500ms EaseOut),如同点亮一盏香薰灯。这种交互的心理学意义:

  • 神秘感:未点亮时星星几乎透明(opacity 0.15),用户不知道具体评分,增加好奇心
  • 仪式感:点亮过程缓慢渐变(500ms),如同点亮一盏香薰灯的仪式,增加每条香评评分展示的仪式感
  • 参与感:用户需要主动点击卡片才能看到评分,增加参与感和互动性
  • 惊喜感:点亮后看到完整评分,如同揭开神秘面纱,带来惊喜感

评分点亮交互是嗅觉仪式感的数字化表达,让每条香评的评分展示都成为一个小仪式。500ms的缓慢渐变时长是关键,太短则没有仪式感,太长则显得拖沓。500ms EaseOut曲线让点亮过程先快后慢,如同香薰灯从点亮到稳定燃烧的过程。

13.5 香型偏好与嗅觉自我认知

香型偏好可视化(5维条形图)是我的Tab的核心数据可视化,帮助用户了解自己的嗅觉偏好:

  • 自我认知:香型偏好画像如同嗅觉DNA,让用户直观了解自己偏爱哪些香调(花香90最高,香料38最低)
  • 配方推荐:基于香型偏好可以推荐符合用户口味的配方(如推荐花香调配方给花香偏好高的用户)
  • 调香指导:了解自己的嗅觉偏好可以指导调香创作,在调配时增加高偏好香调的比例
  • 社交展示:香型偏好画像可以作为社交展示,如同"我是花香调爱好者"的身份标签

香型偏好画像是嗅觉自我认知的工具,让用户从"我喜欢这个味道"的感性认知,升级为"我花香偏好90分"的理性认知。这种数据化的自我认知帮助用户更好地了解自己的嗅觉偏好,创造更符合个人风格的香水。

13.6 我的调香作品集与创作成就感

我的调香(8支)是用户的个人调香作品集,记录用户的调香历程:

  • 创作记录:每支调香都有名称、香调、滴数、日期,如同创作作品的档案
  • 成长轨迹:从"初试调香"(6滴,花香)到"冥想檀境"(13滴,东方香),展示用户的调香成长轨迹
  • 成就感:作品集的积累带来创作成就感,8支调香如同8件个人作品
  • 分享欲:作品集可以激发分享欲,用户愿意分享自己的调香作品到社区

我的调香作品集是创作成就感的载体,让用户的每一次调香都有记录、有积累、有成长。从简单的初试调香到复杂的东方香调,作品集见证了用户的调香成长历程,增加了用户粘性和归属感。

13.7 深夜实验室美学与专注状态

多多香薰调香的整体视觉风格是"深夜实验室美学":薰衣夜深紫背景如同深夜的实验室,琥珀金强调色如同实验台上的烛光,紫罗兰辅助色如同实验试剂的紫色,精油卡片的香调色边框如同不同试剂的标签色。这种深夜实验室美学营造专注的调香状态:

  • 沉浸感:深紫背景减少视觉干扰,让用户专注于调香操作
  • 高级感:深紫+琥珀金的配色传递高级感,如同专业调香实验室
  • 神秘感:深紫色调增加神秘感,符合香水和调香的神秘气质
  • 仪式感:深夜实验室的氛围让调香成为一种仪式,而非简单的操作

深夜实验室美学是多多香薰调香的核心视觉识别,让它在众多美妆电商App中独树一帜。深紫背景+琥珀金烛光的配色组合,既神秘又温暖,既高级又亲切,完美契合DIY调香的深夜实验室氛围。


第十四章 开发环境搭建与运行指南

本章为安装软件步骤,按要求放在最后一章。

14.1 开发环境概述

多多香薰调香基于 HarmonyOS ArkTS 声明式 UI(ArkUI)开发,API 9+,Stage 模型,单文件 .ets。开发环境需要:DevEco Studio(HarmonyOS 官方 IDE)、HarmonyOS SDK(API 9+)、Node.js(构建工具依赖)、HarmonyOS 设备或模拟器(运行调试)。

14.2 安装 DevEco Studio

DevEco Studio 是华为官方推出的 HarmonyOS 应用开发 IDE,基于 IntelliJ IDEA 社区版构建,提供 ArkTS/ArkUI 代码编辑、预览、调试、打包等完整开发能力。

安装步骤

  1. 访问华为开发者联盟官网(developer.huawei.com),进入"开发"→"DevEco Studio"下载页面
  2. 选择对应操作系统版本(Windows/macOS)下载安装包
  3. Windows 版运行 .exe 安装程序,按向导完成安装(建议安装路径不含中文和空格)
  4. macOS 版打开 .dmg 镜像,将 DevEco Studio 拖入 Applications 文件夹
  5. 首次启动 DevEco Studio,选择不导入设置,进入欢迎界面
  6. 配置 Node.js 路径(DevEco Studio 内置 Node.js,也可使用本地安装的 Node.js 16+)
  7. 配置 npm 镜像(建议使用华为云 npm 镜像或淘宝镜像,加速依赖下载)

安装DevEco Studio

14.3 配置 HarmonyOS SDK

DevEco Studio 安装完成后,需要配置 HarmonyOS SDK(API 9+)。SDK 包含 ArkTS 编译器、ArkUI 组件库、系统 API、模拟器镜像等。

配置步骤

  1. 打开 DevEco Studio,进入欢迎界面
  2. 点击"Configure"→"Settings"(macOS 为"DevEco Studio"→"Settings")
  3. 左侧导航选择"Appearance & Behavior"→"System Settings"→"HarmonyOS SDK"
  4. 在 SDK Platforms 标签页,勾选 API 9 或更高版本(建议 API 9,兼容性最好)
  5. 在 SDK Tools 标签页,勾选"ArkTS"、“ArkUI”、"Previewer"等工具
  6. 点击"Apply"开始下载安装 SDK(下载大小约 2-4GB,需耐心等待)
  7. 安装完成后,SDK 路径会自动配置,可在 Settings 中查看和修改

SDK 版本说明

  • API 9:首个支持 Stage 模型和 ArkTS 声明式 UI 的稳定版本,兼容性最好
  • API 10+:新增更多 ArkUI 组件和 API,功能更丰富,但兼容性需考虑
  • 建议使用 API 9 开发,确保在更多设备上运行

配置HarmonyOS SDK

14.4 创建 HarmonyOS 项目

SDK 配置完成后,创建一个新的 HarmonyOS 项目。

创建步骤

  1. 打开 DevEco Studio,进入欢迎界面
  2. 点击"Create Project"创建新项目
  3. 选择项目模板:选择"Empty Ability"(空能力模板),这是最基础的 ArkTS 项目模板
  4. 配置项目信息:
    • Project name:项目名称(如 ScentApp)
    • Bundle name:应用包名(如 com.example.scentapp,需唯一)
    • Save location:项目保存路径(建议不含中文和空格)
    • Compile SDK:选择 API 9 或更高版本
    • Model:选择 Stage 模型(ArkTS 声明式 UI 推荐使用 Stage 模型)
    • Language:选择 ArkTS
  5. 点击"Finish"创建项目,DevEco Studio 会自动生成项目结构和基础代码
  6. 等待项目同步完成(Gradle 同步,首次同步可能需要几分钟下载依赖)

项目结构说明

  • entry/src/main/ets/:ArkTS 源代码目录
  • entry/src/main/ets/entryability/:Ability 入口文件
  • entry/src/main/ets/pages/:页面文件目录
  • entry/src/main/resources/:资源文件目录(图片、字符串、主题等)
  • entry/src/main/module.json5:模块配置文件
  • build-profile.json5:构建配置文件

创建项目后,将多多香薰调香的完整代码(2022.ets)复制到 entry/src/main/ets/pages/ 目录下,或替换默认的 Index.ets 文件。注意修改 @Entry 装饰器的组件名和 module.json5 中的入口页面配置。

创建HarmonyOS项目

14.5 运行应用

项目创建和代码导入完成后,可以在真机或模拟器上运行应用。

运行步骤

  1. 连接 HarmonyOS 设备(通过 USB 数据线),或启动模拟器
  2. 真机运行需开启开发者模式和 USB 调试:设置→关于手机→连续点击版本号开启开发者模式→设置→系统和更新→开发人员选项→开启 USB 调试
  3. 模拟器运行:点击 DevEco Studio 顶部工具栏的设备选择器,选择"Device Manager"→创建模拟器→选择设备型号和 API 版本→启动模拟器
  4. 在 DevEco Studio 顶部工具栏,确认运行配置为"entry",设备选择为已连接的真机或已启动的模拟器
  5. 点击运行按钮(绿色三角形 ▶)或按快捷键 Shift+F10(macOS 为 Control+R)
  6. DevEco Studio 会自动编译、打包、安装应用到设备,并启动应用
  7. 首次运行可能需要 1-2 分钟编译时间,后续运行会更快(增量编译)

运行注意事项

  • 确保设备已开启 USB 调试,且已授权电脑调试
  • 确保设备系统版本 >= API 9(HarmonyOS 3.0+)
  • 模拟器运行性能可能不如真机,建议最终在真机上测试
  • 如果运行失败,查看 Build 窗口和 Log 窗口的错误信息

运行应用

14.6 预览与调试

DevEco Studio 提供 ArkUI 预览器和调试工具,方便开发过程中实时查看界面和调试代码。

预览器使用

  1. 打开 .ets 页面文件,右侧会自动显示 Previewer 预览面板
  2. 预览器实时渲染当前页面的 ArkUI 界面,代码修改后自动刷新
  3. 预览器支持多种设备尺寸和方向切换,测试不同屏幕适配
  4. 预览器支持深色/浅色模式切换(多多香薰调香为深色主题,建议在深色模式下预览)
  5. 如果预览器未显示,点击右侧边栏的"Previewer"图标开启

调试工具使用

  1. 在代码行号左侧点击设置断点(红色圆点)
  2. 点击调试按钮(绿色虫子 🐛)或按快捷键 Shift+F9(macOS 为 Control+D)
  3. 应用运行到断点处会暂停,可查看变量值、调用栈、表达式等
  4. 使用调试工具栏的单步跳过(F8)、单步进入(F7)、单步跳出(Shift+F8)等操作逐步执行代码
  5. 在 Variables 面板查看和修改变量值,在 Watches 面板添加监视表达式
  6. 在 Log 面板查看应用日志(console.log 输出和系统日志)

常见问题排查

  • 预览器不显示:检查代码是否有语法错误,查看 Previewer 面板的错误提示
  • 运行失败:检查 SDK 版本是否匹配,查看 Build 窗口的编译错误
  • 界面显示异常:检查 ArkUI 组件属性是否正确,使用预览器实时调试
  • 动画不流畅:检查是否有过多同时运行的动画,优化动画时长和曲线

多多香薰调香的 7 组交互动画(Tab图标缩放、精油按压缩放、调香滴数跳动、比例条动画、强度条动画、配方点赞缩放、社区评分点亮)建议在真机上测试,确保动画流畅度和交互反馈符合预期。预览器可以查看静态布局,但动画效果需要在真机或模拟器上运行才能完整体验。

预览与调试


总结与展望

核心回顾

多多香薰调香是一款拼多多风格DIY香水/香薰调香社区概念应用,基于 HarmonyOS ArkTS 声明式 UI(ArkUI)开发,API 9+,Stage 模型,单文件 .ets,约 1400 行代码。应用以薰衣夜 #1E1033 深紫为背景,琥珀金 #FFB300 为主强调色,紫罗兰 #9D6DD1 为辅助强调色,营造深夜调香实验室的神秘氛围。

6 个 Tab 覆盖完整业务链路:精油馆(18支精油,香调筛选+双列宫格+按压缩放+购买弹框)、调香工作台(6支已选精油,滴数微调+比例条+强度计+保存配方弹框)、配方库(12个配方,点赞缩放+人气条+编辑+删除确认弹框)、瓶器馆(15件瓶器,三列紧凑宫格)、香友圈(10条香评,评分点亮动画+点赞)、我的(资料卡+香型偏好5维条形图+8支我的调香+6笔购买记录+调香课预约弹框)。

5 套弹框各具特色:购买精油(容量+数量stepper+合计)、保存调香(名称+香调类型+描述+可见范围)、编辑配方(名称+精油滴数+难度+备注)、删除配方确认(橙色警告)、预约调香课(课程类型+时间+地点+费用)。7 组交互动画:Tab图标缩放(scale 1.15)、精油按压缩放(scale 0.96)、调香滴数跳动(scale 1.45)、比例条动画(350ms)、强度条动画(350ms)、配方点赞缩放(scale 1.25)、社区评分点亮(opacity 0.15→1,500ms)。23 个全局纯函数处理数据转换和业务逻辑,11 个 TypeScript 接口描述数据模型,约 15 组全局数据常量。

设计亮点

  1. 深夜实验室美学:薰衣夜深紫背景+琥珀金烛光强调色,营造神秘、沉浸、高级的调香实验室氛围,在众多美妆电商App中独树一帜
  2. 滴数跳动三重联动:调香工作台的滴数跳动+比例条动画+强度条动画三重联动,每次调整滴数都有丰富的视觉反馈,如同真实调香实验室的多仪器联动检测
  3. 评分点亮仪式感:社区香评的评分点亮动画(opacity 0.15→1,500ms EaseOut),如同点亮一盏香薰灯,增加社区互动的神秘感和仪式感
  4. 香型偏好画像:5维香型偏好条形图(花香90/木香74/果香66/草本52/香料38),如同嗅觉DNA,帮助用户了解自己的嗅觉偏好
  5. 前中后调结构可视化:调香比例条实时展示前中后调各调比例,精油卡片香调色边框区分前中后调,配方前中后调标签展示香调结构
  6. 拼多多式高转化+调香专业感:满99包邮、限时折扣、30ml装更划算等高转化元素,与拉丁学名、GC/MS检测、前中后调结构等专业感内容并存
  7. 单文件零依赖:约1400行代码在一个.ets文件中,复制即运行,无第三方依赖,适合学习和快速原型验证

技术价值

多多香薰调香展示了 ArkTS 声明式 UI 的完整开发能力:@Component/@Entry 组件化架构、@State 状态管理、@Builder 自定义构建函数、ForEach 列表渲染(含键值优化)、属性动画(.animation())、Stack 弹框叠加、Flex 布局、Scroll 滚动、TextInput/TextArea 表单、Divider 分割线、TouchEvent 触摸事件等。23 个全局纯函数展示了函数式编程在 ArkTS 中的应用,不可变更新方式(返回新数组而非修改原数组)符合状态管理最佳实践。

未来展望

多多香薰调香作为概念应用,未来可扩展方向:

  1. 真实图片替换 emoji:将精油、瓶器、头像的 emoji 占位替换为真实产品图片,提升视觉真实感
  2. 后端接口对接:将静态数据常量替换为真实后端 API,实现精油库存、配方存储、香评发布、用户系统等真实业务功能
  3. 调香配方推荐算法:基于香型偏好画像和调香历史,使用推荐算法为用户推荐个性化配方
  4. AR 试香体验:结合 HarmonyOS AR 能力,实现虚拟试香和香气可视化
  5. 社交功能增强:增加评论、关注、私信、配方分享等社交功能,打造完整的香友社区
  6. 调香课在线预约:对接真实预约系统,实现调香课在线预约、支付、签到
  7. 数据统计与分析:增加调香数据统计、消费分析、香型偏好变化趋势等数据可视化功能
  8. 多端适配:适配手机、平板、折叠屏等多种设备,支持横屏和分屏模式

多多香薰调香以拼多多式高性价比电商模式+DIY手作调香体验+深夜实验室美学,展示了美妆电商App的创新可能性。它不仅是一个概念应用,更是 ArkTS 声明式 UI 开发的完整教学案例,涵盖组件化架构、状态管理、交互动画、数据可视化、弹框设计等核心技术点。希望本文能为 HarmonyOS 开发者和美妆电商从业者提供有价值的参考。

多多香薰调香总结


附录:完整 ArkTS 源码


// ============================================================
// 2022.ets —— 多多香薰调香(拼多多风格 · DIY 香水/香薰调香社区)
// 主题:薰衣夜 #1E1033 底 × 琥珀金 #FFB300
// 6 Tab 单排:精油/调香/配方/瓶器/社区/我的
// ============================================================

// ---------- 接口定义 ----------

interface OilItem {
  id: number
  name: string
  botanical: string
  note: string
  family: string
  price: number
  volume: number
  purity: string
  icon: string
  hot: boolean
}

interface BlendOil {
  id: number
  oilName: string
  note: string
  drops: number
  icon: string
}

interface OilDrop {
  name: string
  drops: number
}

interface RecipeItem {
  id: number
  name: string
  creator: string
  profile: string
  notes: string[]
  oils: OilDrop[]
  difficulty: string
  likes: number
  loved: boolean
  popularity: number
}

interface VesselItem {
  id: number
  name: string
  type: string
  material: string
  capacity: string
  price: number
  icon: string
}

interface ReviewItem {
  id: number
  user: string
  avatar: string
  blendName: string
  rating: number
  text: string
  scentDesc: string
  likes: number
  loved: boolean
}

interface MyBlendItem {
  id: number
  name: string
  profile: string
  drops: number
  date: string
  icon: string
}

interface PurchaseItem {
  id: number
  oilName: string
  volume: string
  qty: number
  price: number
  date: string
}

interface ScentProfileBar {
  family: string
  score: number
}

interface ScentPalette {
  bg: string
  primary: string
  secondary: string
  card: string
  cardDeep: string
  text: string
  textSub: string
  chip: string
  warn: string
  white: string
}

// ---------- 调色板(暗紫夜调主题) ----------

const SP: ScentPalette = {
  bg: '#1E1033',
  primary: '#FFB300',
  secondary: '#9D6DD1',
  card: '#2A1A40',
  cardDeep: '#221334',
  text: '#E8DEF0',
  textSub: '#9B7AB8',
  chip: '#33224A',
  warn: '#FF7043',
  white: '#FFFFFF'
}

// ---------- 静态选项 ----------

const VOL_OPTIONS: number[] = [5, 10, 30]
const PROFILES: string[] = ['花香', '木香', '果香', '东方香']
const DIFFICULTIES: string[] = ['入门', '进阶', '大师']
const COURSE_TYPES: string[] = ['入门', '进阶', '专题']
const COURSE_SLOTS: string[] = ['周六 14:00', '周六 19:00', '周日 10:00', '周日 15:00']

// ---------- 纯函数 ----------

function noteColor(note: string): string {
  if (note === '前调') {
    return '#FFD54F'
  }
  if (note === '中调') {
    return '#B388FF'
  }
  return '#FF8A65'
}

function oilsByNote(list: OilItem[], note: string): OilItem[] {
  if (note === '全调') {
    return list
  }
  return list.filter((o: OilItem) => o.note === note)
}

function volumePrice(price: number, vol: number): number {
  if (vol === 5) {
    return Math.round(price * 0.6)
  }
  if (vol === 10) {
    return price
  }
  return Math.round(price * 2.6)
}

function dropsOfNote(list: BlendOil[], note: string): number {
  let t: number = 0
  list.forEach((o: BlendOil) => {
    if (o.note === note) {
      t += o.drops
    }
  })
  return t
}

function totalDrops(list: BlendOil[]): number {
  let t: number = 0
  list.forEach((o: BlendOil) => {
    t += o.drops
  })
  return t
}

function noteRatioW(list: BlendOil[], note: string): number {
  const total: number = totalDrops(list)
  if (total === 0) {
    return 0
  }
  return dropsOfNote(list, note) / total * 100
}

function withBlendDrops(list: BlendOil[], id: number, drops: number): BlendOil[] {
  return list.map((o: BlendOil) => {
    if (o.id !== id) {
      return o
    }
    const r: BlendOil = {
      id: o.id,
      oilName: o.oilName,
      note: o.note,
      drops: drops < 0 ? 0 : (drops > 20 ? 20 : drops),
      icon: o.icon
    }
    return r
  })
}

function strengthLevel(total: number): string {
  if (total === 0) {
    return '空瓶'
  }
  if (total < 10) {
    return '淡雅'
  }
  if (total < 20) {
    return '均衡'
  }
  if (total < 30) {
    return '浓郁'
  }
  return '强烈'
}

function strengthColor(total: number): string {
  if (total < 10) {
    return SP.secondary
  }
  if (total < 20) {
    return SP.primary
  }
  if (total < 30) {
    return '#FF8A65'
  }
  return SP.warn
}

function strengthBarW(total: number): number {
  if (total > 40) {
    return 100
  }
  return total / 40 * 100
}

function difficultyColor(d: string): string {
  if (d === '入门') {
    return SP.secondary
  }
  if (d === '进阶') {
    return SP.primary
  }
  return SP.warn
}

function withRecipeLoved(list: RecipeItem[], id: number): RecipeItem[] {
  return list.map((r: RecipeItem) => {
    if (r.id !== id) {
      return r
    }
    const n: RecipeItem = {
      id: r.id,
      name: r.name,
      creator: r.creator,
      profile: r.profile,
      notes: r.notes,
      oils: r.oils,
      difficulty: r.difficulty,
      likes: r.likes + (r.loved ? -1 : 1),
      loved: !r.loved,
      popularity: r.popularity
    }
    return n
  })
}

function withoutRecipe(list: RecipeItem[], id: number): RecipeItem[] {
  return list.filter((r: RecipeItem) => r.id !== id)
}

function withOilDrops(list: OilDrop[], name: string, drops: number): OilDrop[] {
  return list.map((d: OilDrop) => {
    if (d.name !== name) {
      return d
    }
    const r: OilDrop = {
      name: d.name,
      drops: drops < 0 ? 0 : (drops > 15 ? 15 : drops)
    }
    return r
  })
}

function withRecipeSaved(list: RecipeItem[], id: number, name: string, oils: OilDrop[], difficulty: string): RecipeItem[] {
  return list.map((r: RecipeItem) => {
    if (r.id !== id) {
      return r
    }
    const n: RecipeItem = {
      id: r.id,
      name: name.length > 0 ? name : r.name,
      creator: r.creator,
      profile: r.profile,
      notes: r.notes,
      oils: oils,
      difficulty: difficulty,
      likes: r.likes,
      loved: r.loved,
      popularity: r.popularity
    }
    return n
  })
}

function ratingStars(r: number): string {
  let t: string = ''
  let i: number = 1
  while (i <= 5) {
    if (i <= r) {
      t += '★'
    } else {
      t += '☆'
    }
    i += 1
  }
  return t
}

function isRevealed(list: number[], id: number): boolean {
  return list.indexOf(id) >= 0
}

function toggleReveal(list: number[], id: number): number[] {
  if (list.indexOf(id) >= 0) {
    return list.filter((x: number) => x !== id)
  }
  const r: number[] = list.slice()
  r.push(id)
  return r
}

function profileColor(f: string): string {
  if (f === '花香') {
    return '#F48FB1'
  }
  if (f === '木香') {
    return '#A1887F'
  }
  if (f === '果香') {
    return '#FFD54F'
  }
  if (f === '草本') {
    return '#81C784'
  }
  return '#FF8A65'
}

function coursePrice(t: string): number {
  if (t === '入门') {
    return 99
  }
  if (t === '进阶') {
    return 199
  }
  return 328
}

function courseDesc(t: string): string {
  if (t === '入门') {
    return '从认识前中后三调开始,带走第一支亲手调配的 10ml 香水'
  }
  if (t === '进阶') {
    return '香调结构与配比进阶,含 3 支成品与气味档案建立'
  }
  return '东方香调 / 天然香水专题,大师配方逐层拆解'
}

// ---------- 数据:精油(18 支) ----------

const OILS: OilItem[] = [
  { id: 1, name: '薰衣草', botanical: 'Lavandula angustifolia', note: '中调', family: '花香', price: 68, volume: 10, purity: '纯度 99.6%', icon: '💜', hot: true },
  { id: 2, name: '甜橙', botanical: 'Citrus sinensis', note: '前调', family: '果香', price: 45, volume: 10, purity: '纯度 99.2%', icon: '🍊', hot: true },
  { id: 3, name: '大西洋雪松', botanical: 'Cedrus atlantica', note: '后调', family: '木香', price: 72, volume: 10, purity: '纯度 99.4%', icon: '🌲', hot: false },
  { id: 4, name: '佛手柑', botanical: 'Citrus bergamia', note: '前调', family: '果香', price: 88, volume: 10, purity: '纯度 99.5%', icon: '🍋', hot: true },
  { id: 5, name: '大马士革玫瑰', botanical: 'Rosa damascena', note: '中调', family: '花香', price: 328, volume: 5, purity: '纯度 99.8%', icon: '🌹', hot: true },
  { id: 6, name: '茉莉原精', botanical: 'Jasminum grandiflorum', note: '中调', family: '花香', price: 298, volume: 5, purity: '纯度 99.7%', icon: '🤍', hot: false },
  { id: 7, name: '印度檀香', botanical: 'Santalum album', note: '后调', family: '木香', price: 198, volume: 10, purity: '纯度 99.6%', icon: '🪵', hot: true },
  { id: 8, name: '欧薄荷', botanical: 'Mentha piperita', note: '前调', family: '草本', price: 38, volume: 10, purity: '纯度 99.1%', icon: '🌿', hot: false },
  { id: 9, name: '依兰依兰', botanical: 'Cananga odorata', note: '中调', family: '花香', price: 128, volume: 10, purity: '纯度 99.3%', icon: '🌺', hot: false },
  { id: 10, name: '岩兰草', botanical: 'Vetiveria zizanoides', note: '后调', family: '草本', price: 118, volume: 10, purity: '纯度 99.4%', icon: '🌾', hot: false },
  { id: 11, name: '索马里乳香', botanical: 'Boswellia carterii', note: '后调', family: '树脂', price: 158, volume: 10, purity: '纯度 99.5%', icon: '⭐', hot: false },
  { id: 12, name: '澳洲茶树', botanical: 'Melaleuca alternifolia', note: '前调', family: '草本', price: 52, volume: 10, purity: '纯度 99.2%', icon: '🍃', hot: false },
  { id: 13, name: '波旁天竺葵', botanical: 'Pelargonium graveolens', note: '中调', family: '花香', price: 85, volume: 10, purity: '纯度 99.3%', icon: '🌸', hot: false },
  { id: 14, name: '粉红葡萄柚', botanical: 'Citrus paradisi', note: '前调', family: '果香', price: 48, volume: 10, purity: '纯度 99.2%', icon: '🍑', hot: true },
  { id: 15, name: '广藿香', botanical: 'Pogostemon cablin', note: '后调', family: '香料', price: 138, volume: 10, purity: '纯度 99.4%', icon: '🍂', hot: false },
  { id: 16, name: '迷迭香', botanical: 'Rosmarinus officinalis', note: '前调', family: '草本', price: 42, volume: 10, purity: '纯度 99.1%', icon: '🌱', hot: false },
  { id: 17, name: '罗马洋甘菊', botanical: 'Chamaemelum nobile', note: '中调', family: '草本', price: 108, volume: 5, purity: '纯度 99.6%', icon: '🌼', hot: false },
  { id: 18, name: '甜茴香', botanical: 'Foeniculum vulgare', note: '前调', family: '香料', price: 62, volume: 10, purity: '纯度 99.2%', icon: '🌾', hot: false }
]

// ---------- 数据:调香工作台已选精油 ----------

const BLEND_OILS: BlendOil[] = [
  { id: 1, oilName: '甜橙', note: '前调', drops: 4, icon: '🍊' },
  { id: 2, oilName: '佛手柑', note: '前调', drops: 3, icon: '🍋' },
  { id: 3, oilName: '薰衣草', note: '中调', drops: 5, icon: '💜' },
  { id: 4, oilName: '茉莉原精', note: '中调', drops: 2, icon: '🤍' },
  { id: 5, oilName: '大西洋雪松', note: '后调', drops: 3, icon: '🌲' },
  { id: 6, oilName: '印度檀香', note: '后调', drops: 2, icon: '🪵' }
]

// ---------- 数据:配方(12 个) ----------

const RECIPES: RecipeItem[] = [
  { id: 1, name: '仲夏夜之梦', creator: '调香师·沈墨', profile: '花香', notes: ['前调·果香', '中调·花香', '后调·木香'], oils: [{ name: '甜橙', drops: 4 }, { name: '薰衣草', drops: 5 }, { name: '大西洋雪松', drops: 3 }], difficulty: '入门', likes: 486, loved: true, popularity: 96 },
  { id: 2, name: '雨后森林', creator: '香友_山野', profile: '木香', notes: ['前调·草本', '中调·木香', '后调·树脂'], oils: [{ name: '迷迭香', drops: 3 }, { name: '大西洋雪松', drops: 4 }, { name: '印度檀香', drops: 3 }, { name: '索马里乳香', drops: 2 }], difficulty: '进阶', likes: 352, loved: false, popularity: 78 },
  { id: 3, name: '晨光玫瑰', creator: '调香师·阿岚', profile: '花香', notes: ['前调·果香', '中调·花香'], oils: [{ name: '佛手柑', drops: 4 }, { name: '大马士革玫瑰', drops: 3 }, { name: '波旁天竺葵', drops: 2 }], difficulty: '进阶', likes: 521, loved: false, popularity: 90 },
  { id: 4, name: '深夜书房', creator: '香友_墨白', profile: '木香', notes: ['中调·木香', '后调·木香'], oils: [{ name: '大西洋雪松', drops: 5 }, { name: '印度檀香', drops: 4 }, { name: '岩兰草', drops: 2 }], difficulty: '入门', likes: 289, loved: false, popularity: 65 },
  { id: 5, name: '海盐橙花', creator: '调香师·临风', profile: '果香', notes: ['前调·果香', '中调·花香', '后调·木香'], oils: [{ name: '粉红葡萄柚', drops: 5 }, { name: '波旁天竺葵', drops: 3 }, { name: '大西洋雪松', drops: 2 }], difficulty: '进阶', likes: 433, loved: false, popularity: 84 },
  { id: 6, name: '东方迷雾', creator: '香友_七夜', profile: '东方香', notes: ['前调·香料', '中调·花香', '后调·树脂'], oils: [{ name: '甜茴香', drops: 2 }, { name: '依兰依兰', drops: 3 }, { name: '广藿香', drops: 4 }, { name: '索马里乳香', drops: 2 }], difficulty: '大师', likes: 612, loved: true, popularity: 99 },
  { id: 7, name: '薄荷清晨', creator: '香友_小满', profile: '果香', notes: ['前调·草本', '中调·果香'], oils: [{ name: '欧薄荷', drops: 3 }, { name: '粉红葡萄柚', drops: 4 }, { name: '甜橙', drops: 3 }], difficulty: '入门', likes: 198, loved: false, popularity: 52 },
  { id: 8, name: '白茶时光', creator: '调香师·青梧', profile: '花香', notes: ['前调·果香', '中调·花香', '后调·草本'], oils: [{ name: '佛手柑', drops: 3 }, { name: '罗马洋甘菊', drops: 4 }, { name: '岩兰草', drops: 3 }], difficulty: '进阶', likes: 375, loved: false, popularity: 71 },
  { id: 9, name: '乌木沉香', creator: '香友_沉香木', profile: '木香', notes: ['中调·木香', '后调·树脂'], oils: [{ name: '印度檀香', drops: 5 }, { name: '索马里乳香', drops: 3 }, { name: '广藿香', drops: 3 }], difficulty: '大师', likes: 458, loved: false, popularity: 88 },
  { id: 10, name: '初恋柠檬', creator: '香友_柚子茶', profile: '果香', notes: ['前调·果香', '中调·花香'], oils: [{ name: '甜橙', drops: 5 }, { name: '佛手柑', drops: 3 }, { name: '大马士革玫瑰', drops: 2 }], difficulty: '入门', likes: 267, loved: false, popularity: 58 },
  { id: 11, name: '禅意庭园', creator: '调香师·沈墨', profile: '木香', notes: ['前调·草本', '中调·木香', '后调·木香'], oils: [{ name: '迷迭香', drops: 2 }, { name: '大西洋雪松', drops: 4 }, { name: '印度檀香', drops: 4 }], difficulty: '进阶', likes: 341, loved: false, popularity: 74 },
  { id: 12, name: '蜜桃乌龙', creator: '香友_桃桃', profile: '果香', notes: ['前调·果香', '中调·花香', '后调·木香'], oils: [{ name: '粉红葡萄柚', drops: 4 }, { name: '波旁天竺葵', drops: 3 }, { name: '大西洋雪松', drops: 3 }], difficulty: '入门', likes: 302, loved: false, popularity: 69 }
]

// ---------- 数据:瓶器(15 件) ----------

const VESSELS: VesselItem[] = [
  { id: 1, name: '琥珀水晶瓶', type: '香水瓶', material: '水晶', capacity: '10ml', price: 128, icon: '🏺' },
  { id: 2, name: '磨砂滚珠瓶', type: '香水瓶', material: '玻璃', capacity: '10ml', price: 45, icon: '🧴' },
  { id: 3, name: '鎏金细颈瓶', type: '香水瓶', material: '玻璃', capacity: '30ml', price: 88, icon: '⚱️' },
  { id: 4, name: '手工吹制圆瓶', type: '香水瓶', material: '玻璃', capacity: '50ml', price: 168, icon: '🫙' },
  { id: 5, name: '复古喷泵旅行瓶', type: '香水瓶', material: '合金', capacity: '8ml', price: 58, icon: '🧳' },
  { id: 6, name: '藤条扩香瓶', type: '扩香瓶', material: '玻璃', capacity: '120ml', price: 78, icon: '🍶' },
  { id: 7, name: '云朵陶瓷扩香瓶', type: '扩香瓶', material: '陶瓷', capacity: '150ml', price: 118, icon: '☁️' },
  { id: 8, name: '无火藤条套装', type: '扩香瓶', material: '玻璃', capacity: '200ml', price: 98, icon: '🌾' },
  { id: 9, name: '车载出风口香薰', type: '扩香瓶', material: '合金', capacity: '5ml', price: 39, icon: '💨' },
  { id: 10, name: '玫瑰石膏扩香石', type: '扩香瓶', material: '石膏', capacity: '50g', price: 29, icon: '🪨' },
  { id: 11, name: '黑陶香薰炉', type: '香薰炉', material: '陶瓷', capacity: '适用蜡烛', price: 138, icon: '🕯️' },
  { id: 12, name: '电子熏香炉', type: '香薰炉', material: '陶瓷', capacity: 'USB 款', price: 198, icon: '🪔' },
  { id: 13, name: '铜制雕花香炉', type: '香薰炉', material: '黄铜', capacity: '适用线香', price: 268, icon: '🔥' },
  { id: 14, name: '玻璃烛台香薰杯', type: '香薰炉', material: '玻璃', capacity: '适用茶蜡', price: 68, icon: '🕯️' },
  { id: 15, name: '恒温香薰机', type: '香薰炉', material: 'ABS', capacity: '300ml', price: 158, icon: '💧' }
]

// ---------- 数据:社区香评(10 条) ----------

const REVIEWS: ReviewItem[] = [
  { id: 1, user: '香友_薄荷绿', avatar: '🧚', blendName: '仲夏夜之梦', rating: 5, text: '前调甜橙一打开就是夏天,中调薰衣草很温柔,尾调雪松沉稳留香整晚。', scentDesc: '清新花果 · 留香6小时', likes: 128, loved: true },
  { id: 2, user: '香友_墨白', avatar: '🦉', blendName: '深夜书房', rating: 5, text: '檀香加岩兰草的组合太适合伏案夜读,木质气息安静不扰人。', scentDesc: '温暖木质 · 留香8小时', likes: 96, loved: false },
  { id: 3, user: '香友_柚子茶', avatar: '🍑', blendName: '蜜桃乌龙', rating: 4, text: '果香偏甜但不齁,滴在扩香石上整个房间都是桃子味。', scentDesc: '甜润果香 · 留香4小时', likes: 75, loved: false },
  { id: 4, user: '香友_七夜', avatar: '🌙', blendName: '东方迷雾', rating: 5, text: '广藿香配依兰,神秘感拉满,朋友都问我在哪买的。', scentDesc: '神秘东方 · 留香10小时', likes: 210, loved: true },
  { id: 5, user: '香友_小满', avatar: '🌱', blendName: '薄荷清晨', rating: 4, text: '薄荷醒脑第一名,工作日下午补两滴立刻回血。', scentDesc: '清凉草本 · 留香3小时', likes: 64, loved: false },
  { id: 6, user: '香友_山野', avatar: '🌲', blendName: '雨后森林', rating: 5, text: '迷迭香开场很冲,十分钟后就是松针与湿土,还原度极高。', scentDesc: '清冽木质 · 留香7小时', likes: 158, loved: false },
  { id: 7, user: '香友_桃桃', avatar: '🌸', blendName: '初恋柠檬', rating: 3, text: '甜橙比例略高,酸感不足,建议柠檬多加两滴。', scentDesc: '清甜果香 · 留香4小时', likes: 42, loved: false },
  { id: 8, user: '香友_沉香木', avatar: '🪵', blendName: '乌木沉香', rating: 5, text: '大师级配方名不虚传,乳香尾韵像寺庙晨雾。', scentDesc: '醇厚树脂 · 留香12小时', likes: 187, loved: true },
  { id: 9, user: '香友_临风', avatar: '🍃', blendName: '白茶时光', rating: 4, text: '洋甘菊柔和衬得佛手柑很干净,睡前滴枕边刚刚好。', scentDesc: '柔和花香 · 留香5小时', likes: 88, loved: false },
  { id: 10, user: '香友_青梧', avatar: '🦢', blendName: '晨光玫瑰', rating: 5, text: '玫瑰贵有贵的道理,一滴就是一整座花园。', scentDesc: '馥郁花香 · 留香9小时', likes: 245, loved: false }
]

// ---------- 数据:我的调香(8 支) ----------

const MY_BLENDS: MyBlendItem[] = [
  { id: 1, name: '书房晚钟', profile: '木香', drops: 12, date: '08-20', icon: '🪵' },
  { id: 2, name: '周一提神', profile: '草本', drops: 8, date: '08-18', icon: '🌿' },
  { id: 3, name: '桃气乌龙', profile: '果香', drops: 15, date: '08-15', icon: '🍑' },
  { id: 4, name: '睡前雾语', profile: '花香', drops: 10, date: '08-12', icon: '🤍' },
  { id: 5, name: '雨天松针', profile: '木香', drops: 11, date: '08-08', icon: '🌲' },
  { id: 6, name: '甜梦泡泡', profile: '果香', drops: 9, date: '08-03', icon: '🫧' },
  { id: 7, name: '冥想檀境', profile: '东方香', drops: 13, date: '07-28', icon: '🧘' },
  { id: 8, name: '初试调香', profile: '花香', drops: 6, date: '07-20', icon: '💐' }
]

// ---------- 数据:购买记录(6 笔) ----------

const PURCHASES: PurchaseItem[] = [
  { id: 1, oilName: '大马士革玫瑰', volume: '5ml', qty: 1, price: 328, date: '08-18' },
  { id: 2, oilName: '印度檀香', volume: '10ml', qty: 2, price: 396, date: '08-12' },
  { id: 3, oilName: '薰衣草', volume: '10ml', qty: 1, price: 68, date: '08-02' },
  { id: 4, oilName: '佛手柑', volume: '10ml', qty: 3, price: 264, date: '07-25' },
  { id: 5, oilName: '藤条扩香瓶', volume: '120ml', qty: 1, price: 78, date: '07-19' },
  { id: 6, oilName: '甜橙', volume: '10ml', qty: 2, price: 90, date: '07-10' }
]

// ---------- 数据:香型偏好(5 维条形) ----------

const SCENT_PROFILE: ScentProfileBar[] = [
  { family: '花香', score: 90 },
  { family: '木香', score: 74 },
  { family: '果香', score: 66 },
  { family: '草本', score: 52 },
  { family: '香料', score: 38 }
]

// ---------- Tab 枚举 ----------

enum ScentTab {
  OIL = 0,
  BLEND = 1,
  RECIPE = 2,
  VESSEL = 3,
  COMMUNITY = 4,
  MINE = 5
}

// ---------- 入口 ----------

@Entry @Component struct ScentApp {
  @State activeTab: number = ScentTab.OIL

  build() {
    Column() {
      ScentHeader()
      Stack() {
        if (this.activeTab === ScentTab.OIL) {
          OilContent()
        }
        if (this.activeTab === ScentTab.BLEND) {
          BlendContent()
        }
        if (this.activeTab === ScentTab.RECIPE) {
          RecipeContent()
        }
        if (this.activeTab === ScentTab.VESSEL) {
          VesselContent()
        }
        if (this.activeTab === ScentTab.COMMUNITY) {
          CommunityContent()
        }
        if (this.activeTab === ScentTab.MINE) {
          MineContent()
        }
      }
      .layoutWeight(1)
      .width('100%')

      Row() {
        this.tabItem('🧪', '精油', ScentTab.OIL)
        this.tabItem('⚗️', '调香', ScentTab.BLEND)
        this.tabItem('📜', '配方', ScentTab.RECIPE)
        this.tabItem('🏺', '瓶器', ScentTab.VESSEL)
        this.tabItem('💬', '社区', ScentTab.COMMUNITY)
        this.tabItem('👤', '我的', ScentTab.MINE)
      }
      .width('100%')
      .height(58)
      .backgroundColor(SP.cardDeep)
      .padding({ left: 6, right: 6, top: 4 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(SP.bg)
  }

  @Builder
  tabItem(icon: string, label: string, tab: number) {
    Column() {
      Text(icon)
        .fontSize(20)
        .scale({
          x: this.activeTab === tab ? 1.15 : 1,
          y: this.activeTab === tab ? 1.15 : 1
        })
        .animation({ duration: 200, curve: Curve.EaseOut })
      Text(label)
        .fontSize(10)
        .fontColor(this.activeTab === tab ? SP.primary : SP.textSub)
        .margin({ top: 1 })
      Divider()
        .strokeWidth(2)
        .color(this.activeTab === tab ? SP.primary : Color.Transparent)
        .width(16)
        .borderRadius(1)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .onClick(() => {
      this.activeTab = tab
    })
  }
}

// ---------- 头部:香薰电商风静态(无动画) ----------

@Component struct ScentHeader {
  build() {
    Column() {
      Row() {
        Text('🕯️')
          .fontSize(24)
        Column() {
          Text('多多香薰调香')
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.primary)
          Text('PERFUME LAB · 调一杯自己的香气')
            .fontSize(9)
            .fontColor(SP.textSub)
            .margin({ top: 1 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })

        Column()
          .layoutWeight(1)

        Text('🔔')
          .fontSize(19)
          .margin({ right: 14 })
        Text('🛒')
          .fontSize(19)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10, bottom: 8 })

      Row() {
        Text('⌕')
          .fontSize(15)
          .fontColor(SP.textSub)
          .margin({ left: 12 })
        Text('搜精油:薰衣草 / 甜橙 / 雪松')
          .fontSize(12)
          .fontColor(SP.textSub)
          .margin({ left: 6 })
        Column()
          .layoutWeight(1)
        Text('搜索')
          .fontSize(12)
          .fontColor(SP.cardDeep)
          .backgroundColor(SP.primary)
          .borderRadius(13)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .margin({ right: 8 })
      }
      .width('92%')
      .height(34)
      .backgroundColor(SP.card)
      .borderRadius(17)

      Row() {
        Text('⚗️ 调香工坊')
          .fontSize(10)
          .fontColor(SP.primary)
          .backgroundColor('#3A2A10')
          .borderRadius(9)
          .padding({ left: 9, right: 9, top: 4, bottom: 4 })
        Text('🔥 限时折扣')
          .fontSize(10)
          .fontColor(SP.warn)
          .backgroundColor('#3A1E14')
          .borderRadius(9)
          .padding({ left: 9, right: 9, top: 4, bottom: 4 })
        Text('✨ 新香首发')
          .fontSize(10)
          .fontColor(SP.secondary)
          .backgroundColor('#3A2650')
          .borderRadius(9)
          .padding({ left: 9, right: 9, top: 4, bottom: 4 })
        Text('📚 香氛课堂')
          .fontSize(10)
          .fontColor('#81C784')
          .backgroundColor('#1B2E1B')
          .borderRadius(9)
          .padding({ left: 9, right: 9, top: 4, bottom: 4 })
      }
      .width('92%')
      .margin({ top: 10 })
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('100%')
    .backgroundColor(SP.cardDeep)
    .borderRadius({ bottomLeft: 16, bottomRight: 16 })
    .padding({ bottom: 12 })
  }
}

// ---------- Tab1 精油:香调筛选 + 双列宫格(按压缩放动画) ----------

@Component struct OilContent {
  @State oils: OilItem[] = OILS
  @State curNote: string = '全调'
  @State pressedId: number = -1
  @State showBuy: boolean = false
  @State buyOil: OilItem = OILS[0]
  @State buyVol: number = 10
  @State buyQty: number = 1

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .layoutWeight(1)
      .width('100%')
      .onClick(() => {
        onClose()
      })
  }

  @Builder
  noteChip(label: string) {
    Text(label)
      .fontSize(11)
      .fontColor(this.curNote === label ? SP.cardDeep : SP.text)
      .backgroundColor(this.curNote === label ? SP.primary : SP.chip)
      .borderRadius(11)
      .padding({ left: 12, right: 12, top: 5, bottom: 5 })
      .margin({ left: 8 })
      .onClick(() => {
        this.curNote = label
        this.oils = oilsByNote(OILS, label)
      })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row() {
            Text('🧪')
              .fontSize(28)
            Column() {
              Text('精油馆')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.text)
              Text('单一产区 · GC/MS 检测 · 冷压蒸馏')
                .fontSize(10)
                .fontColor(SP.textSub)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 10 })
            Column()
              .layoutWeight(1)
            Text('满 99 包邮')
              .fontSize(10)
              .fontColor(SP.primary)
              .backgroundColor('#3A2A10')
              .borderRadius(9)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          }
          .width('94%')
          .margin({ top: 12 })

          Row() {
            Text('香调')
              .fontSize(12)
              .fontColor(SP.textSub)
              .margin({ right: 4 })
            this.noteChip('全调')
            this.noteChip('前调')
            this.noteChip('中调')
            this.noteChip('后调')
          }
          .width('94%')
          .margin({ top: 14 })

          Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
            ForEach(this.oils, (o: OilItem) => {
              Column() {
                Row() {
                  Text(o.icon)
                    .fontSize(28)
                  Column() {
                    Text(o.note)
                      .fontSize(9)
                      .fontColor(noteColor(o.note))
                      .backgroundColor(noteColor(o.note) + '26')
                      .borderRadius(6)
                      .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                    Text(o.family)
                      .fontSize(8)
                      .fontColor(SP.textSub)
                      .margin({ top: 3 })
                  }
                  .alignItems(HorizontalAlign.Start)
                  .margin({ left: 8 })
                  Column()
                    .layoutWeight(1)
                  if (o.hot) {
                    Text('HOT')
                      .fontSize(8)
                      .fontColor(SP.warn)
                      .border({ width: 1, color: SP.warn })
                      .borderRadius(6)
                      .padding({ left: 4, right: 4, top: 1, bottom: 1 })
                  }
                }
                .width('100%')

                Text(o.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(SP.text)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .width('100%')
                  .margin({ top: 8 })
                Text(o.botanical)
                  .fontSize(8)
                  .fontColor(SP.textSub)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .width('100%')
                  .margin({ top: 2 })

                Divider()
                  .color('#3A2650')
                  .margin({ top: 8 })

                Row() {
                  Text('¥' + o.price)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(SP.primary)
                  Text('/' + o.volume + 'ml')
                    .fontSize(9)
                    .fontColor(SP.textSub)
                  Column()
                    .layoutWeight(1)
                  Text('购买')
                    .fontSize(10)
                    .fontColor(SP.cardDeep)
                    .backgroundColor(SP.primary)
                    .borderRadius(9)
                    .padding({ left: 9, right: 9, top: 3, bottom: 3 })
                }
                .width('100%')
                .margin({ top: 8 })
              }
              .width('48.5%')
              .backgroundColor(SP.card)
              .borderRadius(12)
              .border({ width: 1.5, color: noteColor(o.note) + '66' })
              .margin({ top: 10 })
              .padding(11)
              .scale({
                x: this.pressedId === o.id ? 0.96 : 1,
                y: this.pressedId === o.id ? 0.96 : 1
              })
              .animation({ duration: 120, curve: Curve.EaseOut })
              .onTouch((event: TouchEvent) => {
                if (event.type === TouchType.Down) {
                  this.pressedId = o.id
                } else {
                  this.pressedId = -1
                }
              })
              .onClick(() => {
                this.buyOil = o
                this.buyVol = 10
                this.buyQty = 1
                this.showBuy = true
              })
            })
          }
          .width('94%')
          .margin({ top: 4 })
          .padding({ bottom: 16 })
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .width('100%')
      .height('100%')

      if (this.showBuy) {
        this.buyModal()
      }
    }
    .width('100%')
    .height('100%')
  }

  // 弹框1:购买弹框(底部抽屉)
  @Builder
  buyModal() {
    Column() {
      this.modalOverlay(() => {
        this.showBuy = false
      })

      Column() {
        Row() {
          Text('🛒 购买精油')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.text)
          Column()
            .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(SP.textSub)
            .onClick(() => {
              this.showBuy = false
            })
        }
        .width('100%')
        .padding({ bottom: 12 })

        Row() {
          Text(this.buyOil.icon)
            .fontSize(44)
            .backgroundColor(SP.chip)
            .borderRadius(14)
            .padding(14)
          Column() {
            Text(this.buyOil.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(SP.text)
            Text(this.buyOil.botanical)
              .fontSize(9)
              .fontColor(SP.textSub)
              .margin({ top: 3 })
            Text(this.buyOil.purity + ' · ' + this.buyOil.family + '·' + this.buyOil.note)
              .fontSize(10)
              .fontColor(SP.secondary)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
        }
        .width('100%')

        Row() {
          Text('容量')
            .fontSize(12)
            .fontColor(SP.textSub)
            .width(40)
          ForEach(VOL_OPTIONS, (v: number) => {
            Text(v + 'ml')
              .fontSize(11)
              .fontColor(this.buyVol === v ? SP.cardDeep : SP.text)
              .backgroundColor(this.buyVol === v ? SP.primary : SP.chip)
              .borderRadius(10)
              .padding({ left: 14, right: 14, top: 5, bottom: 5 })
              .margin({ left: 8 })
              .onClick(() => {
                this.buyVol = v
              })
          })
        }
        .width('100%')
        .margin({ top: 16 })

        Row() {
          Text('数量')
            .fontSize(12)
            .fontColor(SP.textSub)
          Column()
            .layoutWeight(1)
          Text('−')
            .fontSize(17)
            .fontColor(SP.textSub)
            .backgroundColor(SP.chip)
            .borderRadius(8)
            .width(30)
            .height(30)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              if (this.buyQty > 1) {
                this.buyQty -= 1
              }
            })
          Text(this.buyQty + ' 瓶')
            .fontSize(13)
            .fontColor(SP.text)
            .margin({ left: 10, right: 10 })
          Text('+')
            .fontSize(17)
            .fontColor(SP.cardDeep)
            .backgroundColor(SP.primary)
            .borderRadius(8)
            .width(30)
            .height(30)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              if (this.buyQty < 20) {
                this.buyQty += 1
              }
            })
        }
        .width('100%')
        .margin({ top: 16 })

        Divider()
          .color('#3A2650')
          .margin({ top: 16 })

        Row() {
          Text('合计')
            .fontSize(12)
            .fontColor(SP.textSub)
          Column()
            .layoutWeight(1)
          Text('¥' + volumePrice(this.buyOil.price, this.buyVol) * this.buyQty)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.primary)
        }
        .width('100%')
        .margin({ top: 12 })

        Text('单价 ¥' + volumePrice(this.buyOil.price, this.buyVol) + '/' + this.buyVol + 'ml · 30ml 装更划算')
          .fontSize(10)
          .fontColor(SP.textSub)
          .width('100%')
          .margin({ top: 4 })

        Text('立即购买')
          .fontSize(15)
          .fontColor(SP.cardDeep)
          .backgroundColor(SP.primary)
          .borderRadius(20)
          .padding({ left: 40, right: 40, top: 10, bottom: 10 })
          .margin({ top: 14 })
          .onClick(() => {
            this.showBuy = false
          })
      }
      .width('100%')
      .backgroundColor(SP.card)
      .borderRadius({ topLeft: 20, topRight: 20 })
      .padding(18)
      .padding({ bottom: 28 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.6)')
    .zIndex(999)
  }
}

// ---------- Tab2 调香:工作台布局(比例条 + 滴数步进 + 强度计) ----------

@Component struct BlendContent {
  @State blendOils: BlendOil[] = BLEND_OILS
  @State bumpId: number = -1
  @State bumpTick: boolean = false
  @State showSave: boolean = false
  @State blendName: string = ''
  @State blendProfile: string = '花香'
  @State blendDesc: string = ''
  @State blendPublic: boolean = true

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .layoutWeight(1)
      .width('100%')
      .onClick(() => {
        onClose()
      })
  }

  @Builder
  ratioBar(label: string) {
    Row() {
      Text(label)
        .fontSize(10)
        .fontColor(noteColor(label))
        .width(26)
      Column() {
        Column()
          .height(8)
          .width(noteRatioW(this.blendOils, label) + '%')
          .backgroundColor(noteColor(label))
          .borderRadius(4)
          .animation({ duration: 350, curve: Curve.EaseOut })
      }
      .layoutWeight(1)
      .backgroundColor(SP.chip)
      .borderRadius(4)
      Text(Math.round(noteRatioW(this.blendOils, label)) + '%')
        .fontSize(10)
        .fontColor(SP.textSub)
        .width(32)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .margin({ top: 10 })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Column() {
            Row() {
              Text('⚗️ 当前配方')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.text)
              Column()
                .layoutWeight(1)
              Text(totalDrops(this.blendOils) + ' 滴')
                .fontSize(12)
                .fontColor(SP.primary)
            }
            .width('100%')

            Text('前调 ' + dropsOfNote(this.blendOils, '前调') + ' 滴 · 中调 ' + dropsOfNote(this.blendOils, '中调') + ' 滴 · 后调 ' + dropsOfNote(this.blendOils, '后调') + ' 滴')
              .fontSize(9)
              .fontColor(SP.textSub)
              .width('100%')
              .margin({ top: 6 })

            this.ratioBar('前调')
            this.ratioBar('中调')
            this.ratioBar('后调')
          }
          .width('94%')
          .backgroundColor(SP.card)
          .borderRadius(14)
          .padding(14)
          .margin({ top: 12 })

          Text('所选精油 · 滴数微调')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.text)
            .width('94%')
            .margin({ top: 16 })

          ForEach(this.blendOils, (o: BlendOil) => {
            Row() {
              Text(o.icon)
                .fontSize(22)
                .width(34)
              Column() {
                Text(o.oilName)
                  .fontSize(13)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(SP.text)
                Text(o.note)
                  .fontSize(9)
                  .fontColor(noteColor(o.note))
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('−')
                .fontSize(16)
                .fontColor(SP.textSub)
                .backgroundColor(SP.chip)
                .borderRadius(8)
                .width(28)
                .height(28)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.bumpId = o.id
                  this.bumpTick = !this.bumpTick
                  this.blendOils = withBlendDrops(this.blendOils, o.id, o.drops - 1)
                })
              Text(o.drops + '')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.primary)
                .width(36)
                .textAlign(TextAlign.Center)
                .scale({
                  x: this.bumpId === o.id && this.bumpTick ? 1.45 : 1,
                  y: this.bumpId === o.id && this.bumpTick ? 1.45 : 1
                })
                .animation({ duration: 180, curve: Curve.EaseOut })
              Text('+')
                .fontSize(16)
                .fontColor(SP.cardDeep)
                .backgroundColor(SP.primary)
                .borderRadius(8)
                .width(28)
                .height(28)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.bumpId = o.id
                  this.bumpTick = !this.bumpTick
                  this.blendOils = withBlendDrops(this.blendOils, o.id, o.drops + 1)
                })
              Text('滴')
                .fontSize(9)
                .fontColor(SP.textSub)
                .margin({ left: 4 })
            }
            .width('94%')
            .backgroundColor(SP.card)
            .borderRadius(12)
            .padding(12)
            .margin({ top: 8 })
          })

          Column() {
            Row() {
              Text('🔥 香气强度')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.text)
              Column()
                .layoutWeight(1)
              Text(strengthLevel(totalDrops(this.blendOils)))
                .fontSize(12)
                .fontColor(strengthColor(totalDrops(this.blendOils)))
            }
            .width('100%')

            Column() {
              Column()
                .height(10)
                .width(strengthBarW(totalDrops(this.blendOils)) + '%')
                .backgroundColor(strengthColor(totalDrops(this.blendOils)))
                .borderRadius(5)
                .animation({ duration: 350, curve: Curve.EaseOut })
            }
            .width('100%')
            .backgroundColor(SP.chip)
            .borderRadius(5)
            .margin({ top: 12 })

            Text('滴数越多香气越浓,建议 10-30 滴之间')
              .fontSize(9)
              .fontColor(SP.textSub)
              .width('100%')
              .margin({ top: 6 })

            Row() {
              Text('保存配方')
                .fontSize(14)
                .fontColor(SP.cardDeep)
                .backgroundColor(SP.primary)
                .borderRadius(18)
                .padding({ left: 34, right: 34, top: 9, bottom: 9 })
                .onClick(() => {
                  this.showSave = true
                })
              Text('清空重来')
                .fontSize(14)
                .fontColor(SP.secondary)
                .border({ width: 1, color: SP.secondary })
                .borderRadius(18)
                .padding({ left: 34, right: 34, top: 8, bottom: 8 })
                .margin({ left: 12 })
                .onClick(() => {
                  this.blendOils = BLEND_OILS
                })
            }
            .width('100%')
            .justifyContent(FlexAlign.Center)
            .margin({ top: 14 })
          }
          .width('94%')
          .backgroundColor(SP.card)
          .borderRadius(14)
          .padding(14)
          .margin({ top: 16, bottom: 16 })
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .width('100%')
      .height('100%')

      if (this.showSave) {
        this.saveModal()
      }
    }
    .width('100%')
    .height('100%')
  }

  // 弹框2:调香保存弹框(居中卡片)
  @Builder
  saveModal() {
    Column() {
      this.modalOverlay(() => {
        this.showSave = false
      })

      Column() {
        Row() {
          Text('保存调香配方')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.text)
          Column()
            .layoutWeight(1)
          Text('✕')
            .fontSize(15)
            .fontColor(SP.textSub)
            .onClick(() => {
              this.showSave = false
            })
        }
        .width('100%')
        .padding({ bottom: 12 })

        Text('配方名称')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
        TextInput({ placeholder: '为你的香气起个名字', text: this.blendName })
          .fontSize(13)
          .fontColor(SP.text)
          .backgroundColor(SP.chip)
          .borderRadius(10)
          .padding({ left: 12, right: 12 })
          .height(40)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.blendName = v
          })

        Text('香调类型')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
          .margin({ top: 14 })
        Row() {
          ForEach(PROFILES, (p: string) => {
            Text(p)
              .fontSize(11)
              .fontColor(this.blendProfile === p ? SP.cardDeep : SP.text)
              .backgroundColor(this.blendProfile === p ? SP.secondary : SP.chip)
              .borderRadius(10)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .margin({ right: 8 })
              .onClick(() => {
                this.blendProfile = p
              })
          })
        }
        .width('100%')
        .margin({ top: 6 })

        Text('香调描述')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
          .margin({ top: 14 })
        TextArea({ placeholder: '记录这支香的故事与适合的场景...', text: this.blendDesc })
          .fontSize(12)
          .fontColor(SP.text)
          .backgroundColor(SP.chip)
          .borderRadius(10)
          .height(70)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.blendDesc = v
          })

        Row() {
          Text('可见范围')
            .fontSize(11)
            .fontColor(SP.textSub)
          Column()
            .layoutWeight(1)
          Text('公开')
            .fontSize(11)
            .fontColor(this.blendPublic ? SP.cardDeep : SP.text)
            .backgroundColor(this.blendPublic ? SP.primary : SP.chip)
            .borderRadius(9)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .onClick(() => {
              this.blendPublic = true
            })
          Text('私密')
            .fontSize(11)
            .fontColor(this.blendPublic ? SP.text : SP.cardDeep)
            .backgroundColor(this.blendPublic ? SP.chip : SP.primary)
            .borderRadius(9)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .margin({ left: 8 })
            .onClick(() => {
              this.blendPublic = false
            })
        }
        .width('100%')
        .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(SP.textSub)
            .border({ width: 1, color: SP.textSub })
            .borderRadius(16)
            .padding({ left: 24, right: 24, top: 8, bottom: 8 })
            .margin({ top: 16 })
            .onClick(() => {
              this.showSave = false
            })
          Text('保存')
            .fontSize(13)
            .fontColor(SP.cardDeep)
            .backgroundColor(SP.primary)
            .borderRadius(16)
            .padding({ left: 24, right: 24, top: 8, bottom: 8 })
            .margin({ top: 16, left: 12 })
            .onClick(() => {
              this.showSave = false
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('86%')
      .backgroundColor(SP.card)
      .borderRadius(18)
      .padding(18)

      this.modalOverlay(() => {
        this.showSave = false
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.6)')
    .zIndex(999)
  }
}

// ---------- Tab3 配方:纵向卡片列表(点赞缩放 + 人气迷你条) ----------

@Component struct RecipeContent {
  @State recipes: RecipeItem[] = RECIPES
  @State showEdit: boolean = false
  @State showDelete: boolean = false
  @State editId: number = 0
  @State editName: string = ''
  @State editOils: OilDrop[] = []
  @State editDifficulty: string = '入门'
  @State editNote: string = ''
  @State deleteTarget: RecipeItem = RECIPES[0]

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .layoutWeight(1)
      .width('100%')
      .onClick(() => {
        onClose()
      })
  }

  openEdit(r: RecipeItem) {
    this.editId = r.id
    this.editName = r.name
    this.editOils = r.oils.slice()
    this.editDifficulty = r.difficulty
    this.editNote = ''
    this.showEdit = true
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row() {
            Text('📜 热门配方')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(SP.text)
            Column()
              .layoutWeight(1)
            Text('一键套用 · 可微调')
              .fontSize(10)
              .fontColor(SP.secondary)
              .backgroundColor('#3A2650')
              .borderRadius(9)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          }
          .width('94%')
          .margin({ top: 12 })

          ForEach(this.recipes, (r: RecipeItem) => {
            Column() {
              Row() {
                Column() {
                  Text(r.name)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(SP.text)
                  Text('by ' + r.creator + ' · ' + r.profile)
                    .fontSize(10)
                    .fontColor(SP.textSub)
                    .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                Text(r.loved ? '❤️' : '🤍')
                  .fontSize(20)
                  .scale({
                    x: r.loved ? 1.25 : 1,
                    y: r.loved ? 1.25 : 1
                  })
                  .animation({ duration: 200, curve: Curve.EaseOut })
                  .onClick(() => {
                    this.recipes = withRecipeLoved(this.recipes, r.id)
                  })
                Text(r.likes + '')
                  .fontSize(10)
                  .fontColor(SP.textSub)
                  .margin({ left: 4 })
              }
              .width('100%')

              Row() {
                ForEach(r.notes, (n: string) => {
                  Text(n)
                    .fontSize(9)
                    .fontColor(SP.secondary)
                    .backgroundColor('#3A2650')
                    .borderRadius(7)
                    .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                    .margin({ right: 6 })
                })
              }
              .width('100%')
              .margin({ top: 8 })

              Row() {
                ForEach(r.oils, (d: OilDrop) => {
                  Text(d.name + '×' + d.drops + '滴')
                    .fontSize(9)
                    .fontColor(SP.text)
                    .backgroundColor(SP.chip)
                    .borderRadius(7)
                    .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                    .margin({ right: 6, top: 6 })
                })
              }
              .width('100%')
              .margin({ top: 6 })

              Row() {
                Text(r.difficulty)
                  .fontSize(9)
                  .fontColor(difficultyColor(r.difficulty))
                  .backgroundColor(difficultyColor(r.difficulty) + '2E')
                  .borderRadius(7)
                  .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                Column() {
                  Column()
                    .height(5)
                    .width(r.popularity + '%')
                    .backgroundColor(SP.primary)
                    .borderRadius(3)
                }
                .layoutWeight(1)
                .backgroundColor(SP.chip)
                .borderRadius(3)
                .margin({ left: 10, right: 10 })
                Text('人气 ' + r.popularity + '%')
                  .fontSize(9)
                  .fontColor(SP.textSub)
                Text('编辑')
                  .fontSize(10)
                  .fontColor(SP.secondary)
                  .margin({ left: 10 })
                  .onClick(() => {
                    this.openEdit(r)
                  })
                Text('删除')
                  .fontSize(10)
                  .fontColor(SP.warn)
                  .margin({ left: 10 })
                  .onClick(() => {
                    this.deleteTarget = r
                    this.showDelete = true
                  })
              }
              .width('100%')
              .margin({ top: 10 })
            }
            .width('94%')
            .backgroundColor(SP.card)
            .borderRadius(14)
            .padding(12)
            .margin({ top: 10 })
          })

          Text('💡 配方仅供参考,请根据个人嗅觉记忆微调滴数')
            .fontSize(10)
            .fontColor(SP.textSub)
            .width('94%')
            .margin({ top: 12, bottom: 16 })
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .width('100%')
      .height('100%')

      if (this.showEdit) {
        this.editModal()
      }
      if (this.showDelete) {
        this.deleteModal()
      }
    }
    .width('100%')
    .height('100%')
  }

  // 弹框3:编辑配方弹框(底部表单)
  @Builder
  editModal() {
    Column() {
      this.modalOverlay(() => {
        this.showEdit = false
      })

      Column() {
        Row() {
          Text('✏️ 编辑配方')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.text)
          Column()
            .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(SP.textSub)
            .onClick(() => {
              this.showEdit = false
            })
        }
        .width('100%')
        .padding({ bottom: 12 })

        Text('配方名称')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
        TextInput({ placeholder: '输入配方名称', text: this.editName })
          .fontSize(13)
          .fontColor(SP.text)
          .backgroundColor(SP.chip)
          .borderRadius(10)
          .padding({ left: 12, right: 12 })
          .height(40)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.editName = v
          })

        Text('精油滴数')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
          .margin({ top: 14 })
        ForEach(this.editOils, (d: OilDrop) => {
          Row() {
            Text(d.name)
              .fontSize(12)
              .fontColor(SP.text)
              .layoutWeight(1)
            Text('−')
              .fontSize(15)
              .fontColor(SP.textSub)
              .backgroundColor(SP.chip)
              .borderRadius(8)
              .width(26)
              .height(26)
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.editOils = withOilDrops(this.editOils, d.name, d.drops - 1)
              })
            Text(d.drops + '滴')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(SP.primary)
              .width(44)
              .textAlign(TextAlign.Center)
            Text('+')
              .fontSize(15)
              .fontColor(SP.cardDeep)
              .backgroundColor(SP.primary)
              .borderRadius(8)
              .width(26)
              .height(26)
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.editOils = withOilDrops(this.editOils, d.name, d.drops + 1)
              })
          }
          .width('100%')
          .margin({ top: 8 })
        })

        Text('难度')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
          .margin({ top: 14 })
        Row() {
          ForEach(DIFFICULTIES, (g: string) => {
            Text(g)
              .fontSize(11)
              .fontColor(this.editDifficulty === g ? SP.cardDeep : SP.text)
              .backgroundColor(this.editDifficulty === g ? SP.primary : SP.chip)
              .borderRadius(10)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .margin({ right: 8 })
              .onClick(() => {
                this.editDifficulty = g
              })
          })
        }
        .width('100%')
        .margin({ top: 6 })

        Text('备注')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
          .margin({ top: 14 })
        TextArea({ placeholder: '记录调香心得...', text: this.editNote })
          .fontSize(12)
          .fontColor(SP.text)
          .backgroundColor(SP.chip)
          .borderRadius(10)
          .height(60)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.editNote = v
          })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(SP.textSub)
            .border({ width: 1, color: SP.textSub })
            .borderRadius(16)
            .padding({ left: 24, right: 24, top: 8, bottom: 8 })
            .margin({ top: 16 })
            .onClick(() => {
              this.showEdit = false
            })
          Text('保存修改')
            .fontSize(13)
            .fontColor(SP.cardDeep)
            .backgroundColor(SP.primary)
            .borderRadius(16)
            .padding({ left: 24, right: 24, top: 8, bottom: 8 })
            .margin({ top: 16, left: 12 })
            .onClick(() => {
              this.recipes = withRecipeSaved(this.recipes, this.editId, this.editName, this.editOils, this.editDifficulty)
              this.showEdit = false
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .backgroundColor(SP.card)
      .borderRadius({ topLeft: 20, topRight: 20 })
      .padding(18)
      .padding({ bottom: 28 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.6)')
    .zIndex(999)
  }

  // 弹框4:删除配方确认(居中警示)
  @Builder
  deleteModal() {
    Column() {
      this.modalOverlay(() => {
        this.showDelete = false
      })

      Column() {
        Text('⚠️ 删除配方')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(SP.warn)
        Text('确认删除「' + this.deleteTarget.name + '」?')
          .fontSize(13)
          .fontColor(SP.text)
          .margin({ top: 12 })
        Text('删除后无法恢复,配方将移出配方库。')
          .fontSize(10)
          .fontColor(SP.textSub)
          .margin({ top: 6 })
        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(SP.textSub)
            .border({ width: 1, color: SP.textSub })
            .borderRadius(16)
            .padding({ left: 22, right: 22, top: 8, bottom: 8 })
            .onClick(() => {
              this.showDelete = false
            })
          Text('确认删除')
            .fontSize(13)
            .fontColor(SP.white)
            .backgroundColor(SP.warn)
            .borderRadius(16)
            .padding({ left: 22, right: 22, top: 8, bottom: 8 })
            .margin({ left: 12 })
            .onClick(() => {
              this.recipes = withoutRecipe(this.recipes, this.deleteTarget.id)
              this.showDelete = false
            })
        }
        .margin({ top: 18 })
        .justifyContent(FlexAlign.Center)
      }
      .width('78%')
      .backgroundColor(SP.card)
      .borderRadius(18)
      .padding(20)

      this.modalOverlay(() => {
        this.showDelete = false
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.6)')
    .zIndex(999)
  }
}

// ---------- Tab4 瓶器:三列紧凑宫格 ----------

@Component struct VesselContent {
  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🏺 瓶器与器具')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.text)
          Column()
            .layoutWeight(1)
          Text('顺丰包邮 · 破损必赔')
            .fontSize(10)
            .fontColor(SP.primary)
            .backgroundColor('#3A2A10')
            .borderRadius(9)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        }
        .width('94%')
        .margin({ top: 12 })

        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(VESSELS, (v: VesselItem) => {
            Column() {
              Text(v.icon)
                .fontSize(30)
              Text(v.name)
                .fontSize(11)
                .fontWeight(FontWeight.Medium)
                .fontColor(SP.text)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')
                .textAlign(TextAlign.Center)
                .margin({ top: 6 })
              Text(v.type)
                .fontSize(9)
                .fontColor(SP.secondary)
                .backgroundColor('#3A2650')
                .borderRadius(6)
                .padding({ left: 6, right: 6, top: 1, bottom: 1 })
                .margin({ top: 4 })
              Text(v.material + ' · ' + v.capacity)
                .fontSize(8)
                .fontColor(SP.textSub)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .margin({ top: 4 })
              Text('¥' + v.price)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.primary)
                .margin({ top: 6 })
            }
            .width('31.8%')
            .backgroundColor(SP.card)
            .borderRadius(12)
            .padding(10)
            .margin({ top: 10 })
            .alignItems(HorizontalAlign.Center)
          })
        }
        .width('94%')
        .margin({ top: 4 })
        .padding({ bottom: 16 })
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Vertical)
    .width('100%')
    .height('100%')
  }
}

// ---------- Tab5 社区:香评信息流(评分点亮动画) ----------

@Component struct CommunityContent {
  @State reviews: ReviewItem[] = REVIEWS
  @State revealedIds: number[] = [1]

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row() {
            Text('💬 香友圈')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(SP.text)
            Column()
              .layoutWeight(1)
            Text('真实调配 · 有香必晒')
              .fontSize(10)
              .fontColor(SP.secondary)
              .backgroundColor('#3A2650')
              .borderRadius(9)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          }
          .width('94%')
          .margin({ top: 12 })

          ForEach(this.reviews, (rv: ReviewItem) => {
            Column() {
              Row() {
                Text(rv.avatar)
                  .fontSize(20)
                  .backgroundColor(SP.chip)
                  .borderRadius(18)
                  .width(38)
                  .height(38)
                  .textAlign(TextAlign.Center)
                Column() {
                  Text(rv.user)
                    .fontSize(12)
                    .fontWeight(FontWeight.Medium)
                    .fontColor(SP.text)
                  Text('调配了「' + rv.blendName + '」')
                    .fontSize(10)
                    .fontColor(SP.textSub)
                    .margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })
                Text(ratingStars(rv.rating))
                  .fontSize(13)
                  .fontColor(SP.primary)
                  .opacity(isRevealed(this.revealedIds, rv.id) ? 1 : 0.15)
                  .animation({ duration: 500, curve: Curve.EaseOut })
              }
              .width('100%')

              Text(rv.text)
                .fontSize(11)
                .fontColor(SP.text)
                .width('100%')
                .margin({ top: 8 })

              Row() {
                Text('👃 ' + rv.scentDesc)
                  .fontSize(10)
                  .fontColor(SP.secondary)
                  .backgroundColor('#3A2650')
                  .borderRadius(8)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                Column()
                  .layoutWeight(1)
                Text(rv.loved ? '❤️' : '🤍')
                  .fontSize(14)
                Text(rv.likes + '')
                  .fontSize(10)
                  .fontColor(SP.textSub)
                  .margin({ left: 4 })
              }
              .width('100%')
              .margin({ top: 8 })

              Text(isRevealed(this.revealedIds, rv.id) ? '已点亮评分' : '点击卡片点亮评分')
                .fontSize(8)
                .fontColor(SP.textSub)
                .width('100%')
                .margin({ top: 6 })
            }
            .width('94%')
            .backgroundColor(SP.card)
            .borderRadius(14)
            .padding(12)
            .margin({ top: 10 })
            .onClick(() => {
              this.revealedIds = toggleReveal(this.revealedIds, rv.id)
            })
          })

          Text('— 到底了,快去调一支属于自己的香 —')
            .fontSize(10)
            .fontColor(SP.textSub)
            .width('94%')
            .textAlign(TextAlign.Center)
            .margin({ top: 14, bottom: 16 })
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
  }
}

// ---------- Tab6 我的:资料 + 香型偏好条形 + 我的调香 + 购买记录 ----------

@Component struct MineContent {
  @State showCourse: boolean = false
  @State courseType: string = '入门'
  @State courseSlot: string = '周六 14:00'

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .layoutWeight(1)
      .width('100%')
      .onClick(() => {
        onClose()
      })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row() {
            Text('🧑‍🎤')
              .fontSize(34)
              .backgroundColor(SP.chip)
              .borderRadius(30)
              .width(60)
              .height(60)
              .textAlign(TextAlign.Center)
            Column() {
              Text('香友_8023')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.text)
              Text('调香学徒 Lv.6 · 已调配 32 次')
                .fontSize(10)
                .fontColor(SP.textSub)
                .margin({ top: 3 })
              Text('黑金会员 · 全场精油 95 折')
                .fontSize(9)
                .fontColor(SP.primary)
                .backgroundColor('#3A2A10')
                .borderRadius(8)
                .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                .margin({ top: 5 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 12 })
            Column() {
              Text('12')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.primary)
              Text('配方')
                .fontSize(9)
                .fontColor(SP.textSub)
                .margin({ top: 2 })
            }
            .margin({ right: 10 })
            Column() {
              Text('48')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.secondary)
              Text('关注')
                .fontSize(9)
                .fontColor(SP.textSub)
                .margin({ top: 2 })
            }
            .margin({ right: 10 })
            Column() {
              Text('96')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.text)
              Text('粉丝')
                .fontSize(9)
                .fontColor(SP.textSub)
                .margin({ top: 2 })
            }
          }
          .width('94%')
          .backgroundColor(SP.card)
          .borderRadius(14)
          .padding(14)
          .margin({ top: 12 })

          Column() {
            Text('💗 我的香型偏好')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(SP.text)
              .width('100%')
            ForEach(SCENT_PROFILE, (b: ScentProfileBar) => {
              Row() {
                Text(b.family)
                  .fontSize(10)
                  .fontColor(SP.textSub)
                  .width(30)
                Column() {
                  Column()
                    .height(8)
                    .width(b.score + '%')
                    .backgroundColor(profileColor(b.family))
                    .borderRadius(4)
                }
                .layoutWeight(1)
                .backgroundColor(SP.chip)
                .borderRadius(4)
                Text(b.score + '')
                  .fontSize(10)
                  .fontColor(SP.textSub)
                  .width(26)
                  .textAlign(TextAlign.End)
              }
              .width('100%')
              .margin({ top: 9 })
            })
          }
          .width('94%')
          .backgroundColor(SP.card)
          .borderRadius(14)
          .padding(14)
          .margin({ top: 12 })

          Column() {
            Row() {
              Text('📚 香氛课堂 · 调香大师课')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.text)
              Column()
                .layoutWeight(1)
              Text('99 元起')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(SP.primary)
            }
            .width('100%')
            Text('首席调香师亲授 · 每周六徐汇实验室开课 · 含材料包')
              .fontSize(10)
              .fontColor(SP.textSub)
              .width('100%')
              .margin({ top: 4 })
            Text('预约调香课')
              .fontSize(12)
              .fontColor(SP.cardDeep)
              .backgroundColor(SP.primary)
              .borderRadius(15)
              .padding({ left: 20, right: 20, top: 6, bottom: 6 })
              .margin({ top: 10 })
              .onClick(() => {
                this.showCourse = true
              })
          }
          .width('94%')
          .backgroundColor(SP.card)
          .borderRadius(14)
          .padding(14)
          .margin({ top: 12 })

          Text('🧪 我的调香(8)')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.text)
            .width('94%')
            .margin({ top: 16 })
          ForEach(MY_BLENDS, (b: MyBlendItem) => {
            Row() {
              Text(b.icon)
                .fontSize(20)
                .width(30)
              Column() {
                Text(b.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(SP.text)
                Row() {
                  Text(b.profile)
                    .fontSize(8)
                    .fontColor(SP.secondary)
                    .backgroundColor('#3A2650')
                    .borderRadius(6)
                    .padding({ left: 6, right: 6, top: 1, bottom: 1 })
                  Text(b.drops + ' 滴')
                    .fontSize(9)
                    .fontColor(SP.textSub)
                    .margin({ left: 6 })
                }
                .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 6 })
              Text(b.date)
                .fontSize(10)
                .fontColor(SP.textSub)
            }
            .width('94%')
            .backgroundColor(SP.card)
            .borderRadius(12)
            .padding(10)
            .margin({ top: 8 })
          })

          Text('🧾 购买记录(6)')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.text)
            .width('94%')
            .margin({ top: 16 })
          ForEach(PURCHASES, (p: PurchaseItem) => {
            Row() {
              Text('📦')
                .fontSize(16)
                .width(26)
              Column() {
                Text(p.oilName)
                  .fontSize(12)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(SP.text)
                Text(p.volume + ' × ' + p.qty)
                  .fontSize(9)
                  .fontColor(SP.textSub)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 6 })
              Column() {
                Text('¥' + p.price)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(SP.primary)
                Text(p.date)
                  .fontSize(9)
                  .fontColor(SP.textSub)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('94%')
            .backgroundColor(SP.card)
            .borderRadius(12)
            .padding(10)
            .margin({ top: 8 })
          })

          Text('— 多多香薰调香 · 让香气成为签名 —')
            .fontSize(10)
            .fontColor(SP.textSub)
            .width('94%')
            .textAlign(TextAlign.Center)
            .margin({ top: 14, bottom: 16 })
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .width('100%')
      .height('100%')

      if (this.showCourse) {
        this.courseModal()
      }
    }
    .width('100%')
    .height('100%')
  }

  // 弹框5:预约调香课弹框(底部表单)
  @Builder
  courseModal() {
    Column() {
      this.modalOverlay(() => {
        this.showCourse = false
      })

      Column() {
        Row() {
          Text('📚 预约调香课')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.text)
          Column()
            .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(SP.textSub)
            .onClick(() => {
              this.showCourse = false
            })
        }
        .width('100%')
        .padding({ bottom: 12 })

        Text('课程类型')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
        Row() {
          ForEach(COURSE_TYPES, (t: string) => {
            Text(t)
              .fontSize(11)
              .fontColor(this.courseType === t ? SP.cardDeep : SP.text)
              .backgroundColor(this.courseType === t ? SP.primary : SP.chip)
              .borderRadius(10)
              .padding({ left: 14, right: 14, top: 5, bottom: 5 })
              .margin({ right: 8 })
              .onClick(() => {
                this.courseType = t
              })
          })
        }
        .width('100%')
        .margin({ top: 6 })

        Text('上课时间')
          .fontSize(11)
          .fontColor(SP.textSub)
          .width('100%')
          .margin({ top: 14 })
        Text('2026-09-06(周六)· 单次课 90 分钟')
          .fontSize(12)
          .fontColor(SP.text)
          .width('100%')
          .margin({ top: 4 })
        Row() {
          ForEach(COURSE_SLOTS, (s: string) => {
            Text(s)
              .fontSize(10)
              .fontColor(this.courseSlot === s ? SP.cardDeep : SP.text)
              .backgroundColor(this.courseSlot === s ? SP.secondary : SP.chip)
              .borderRadius(9)
              .padding({ left: 9, right: 9, top: 4, bottom: 4 })
              .margin({ right: 8, top: 6 })
              .onClick(() => {
                this.courseSlot = s
              })
          })
        }
        .width('100%')

        Column() {
          Row() {
            Text('📍 上课地点')
              .fontSize(11)
              .fontColor(SP.textSub)
            Column()
              .layoutWeight(1)
            Text('多多香薰实验室 · 徐汇店')
              .fontSize(11)
              .fontColor(SP.text)
          }
          .width('100%')
          Row() {
            Text('👨‍🏫 授课讲师')
              .fontSize(11)
              .fontColor(SP.textSub)
            Column()
              .layoutWeight(1)
            Text('首席调香师 · 沈墨')
              .fontSize(11)
              .fontColor(SP.text)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .backgroundColor(SP.chip)
        .borderRadius(10)
        .padding(10)
        .margin({ top: 14 })

        Row() {
          Text('课程费用')
            .fontSize(12)
            .fontColor(SP.textSub)
          Column()
            .layoutWeight(1)
          Text('¥' + coursePrice(this.courseType))
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(SP.primary)
        }
        .width('100%')
        .margin({ top: 14 })

        Text(courseDesc(this.courseType))
          .fontSize(10)
          .fontColor(SP.textSub)
          .width('100%')
          .margin({ top: 4 })

        Text('立即预约')
          .fontSize(15)
          .fontColor(SP.cardDeep)
          .backgroundColor(SP.primary)
          .borderRadius(20)
          .padding({ left: 60, right: 60, top: 10, bottom: 10 })
          .margin({ top: 14 })
          .onClick(() => {
            this.showCourse = false
          })
      }
      .width('100%')
      .backgroundColor(SP.card)
      .borderRadius({ topLeft: 20, topRight: 20 })
      .padding(18)
      .padding({ bottom: 28 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.6)')
    .zIndex(999)
  }
}
Logo

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

更多推荐