引言

在这里插入图片描述

在移动端业务应用日益精细化的今天,垂直行业的数字化管理工具正成为开发者关注的新蓝海。香水调香室(Perfume Atelier)应用正是这样一款面向调香工作室的垂直管理工具,它将香材库存、香水产品、调香课程、沙龙活动与私人定制五大业务模块聚合在同一个界面框架内,通过底部分页切换实现业务场景的无缝流转。本文将以该应用的完整源码为基础,逐段拆解其色彩体系设计、数据模型定义、可观察状态管理、主入口组件构建、五大内容页面实现以及四类弹窗交互的每一行代码,深入分析其架构选型背后的工程考量。

从应用背景来看,调香工作室的日常运营涉及极为丰富的信息维度。一款大马士革玫瑰原精不仅要记录名称与产地,还涉及香调分类、库存余量、使用频率与采购单价;一节调香课程需要追踪讲师、级别、座位与报名进度;一场沙龙活动要管理主题、时间、类型与报名热度;一份私人定制订单则需要呈现客户、价格、周期与当前阶段。这种高密度、多维度的数据展示需求,决定了应用必须采用结构化的数据模型与高度复用的展示组件,而非简单的列表堆叠。

在设计理念上,该应用选择了"卡片化信息密度优先"的策略。每个业务模块都以圆角卡片为单位组织信息,卡片内部通过行布局实现标签、数值与进度的并列展示。这种设计使得单屏可承载的信息量远超传统列表式布局,同时通过统一的圆角、边框与色彩体系保持视觉一致性。应用还大量使用进度条、柱状图与排名榜等数据可视化元素,将枯燥的数字转化为直观的视觉信号,降低运营人员的认知负荷。

技术选型方面,该应用基于 ArkTS 声明式开发范式构建。ArkTS 提供了 @Entry、@Component、@State、@Observed、@ObjectLink、@Prop 等一套完整的装饰器体系,使得开发者可以用接近自然语言的方式描述 UI 与数据的关系。应用通过 @Observed 标记数据类实现深层响应式追踪,通过 @ObjectLink 在子组件中建立与父组件数据的双向绑定,通过 @State 管理本地交互状态,通过 @Prop 实现单向数据传递。这种分层状态管理策略,使得大型组件树的状态更新既精准又高效。

色彩体系是该应用设计的一大亮点。整个调色板以雾紫(#9B7EDE)为主色调,搭配香槟金(#C9A227)作为强调色,形成一种兼具奢华感与专业感的视觉基调。背景采用暖白米色(#FBF7F2),卡片使用纯白(#FFFFFF),通过微妙的色温差异营造层次感。文本采用深紫灰(#3E3355)作为主色,既保持了可读性,又与紫系主色调形成和谐的色相关系。辅助色包括玫粉(#D96C8C)用于警示与花香标记、薄荷绿(#7FBF7F)用于成功状态与海洋调、琥珀橙(#E0A458)用于警告与柑橘调,每一抹色彩都承载着特定的语义功能。

组件化策略上,应用采用了"主入口 + 内容页面 + 标签组件 + 弹窗组件"的四层架构。主入口 PerfumeApp 负责顶栏渲染、分页调度与弹窗挂载;五个内容页面组件分别承载五大业务模块的数据展示;PerfumeTag 作为通用标签在多个页面间复用;四个弹窗组件以独立 struct 形式存在,通过条件渲染按需挂载。这种拆分使得每个组件的职责边界清晰,代码可维护性远高于将全部逻辑堆砌在单一 build 方法中的做法。回调函数通过参数传入子组件,实现了子组件向父组件的事件冒泡,这是 ArkTS 中极为常见且实用的跨组件通信模式。

逐段代码分析

在这里插入图片描述

第一段:调色板接口定义

在这里插入图片描述

interface PerfumePalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  grape: string;
  grapeLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  blush: string;
  gold: string;
}

应用以一个独立的 interface 来定义整个调色板的类型约束,这是一种典型的"类型先行"设计策略。通过 PerfumePalette 接口,开发者在编写色彩常量时获得了编译期的类型检查保护,任何遗漏或拼写错误都会被编译器捕获。该接口定义了十九个色彩字段,涵盖了主色三阶(primary、primaryLight、primaryDark)、强调色双阶(accent、accentLight)、葡萄紫双阶(grape、grapeLight)、背景与卡片色、文本三级(主文本、次文本、提示文本)、分割线色、状态色三级(成功、警告、危险)以及装饰色(腮红粉与金色)。

这种将所有色彩集中管理的设计,其工程价值远超简单的代码整洁。在大型项目中,色彩值散落在各处是维护的噩梦:当品牌需要调整主色调时,开发者不得不全局搜索替换十六进制字符串,既容易遗漏又容易引入拼写错误。而通过集中的调色板常量,所有色彩引用都指向同一个数据源,主题调整只需修改一处。此外,接口中的字段命名采用了语义化策略(如 textHint 而非 gray3,danger 而非 red),这使得组件代码在引用色彩时能够直接表达设计意图,提升了代码的可读性。

值得注意的是,accent 与 grape 字段在值上完全相同(均为 #9B7EDE),accentLight 与 grapeLight 也完全一致。这种冗余设计实际上是有意为之:它为未来可能的主题拆分预留了扩展空间。当某天 accent 需要独立于 grape 演变为不同色调时,只需修改常量定义而无需全局替换字段名。这是防御性设计在色彩体系中的具体体现。

第二段:色彩常量实例

const COLORS: PerfumePalette = {
  primary: '#C9A227',
  primaryLight: '#E3C96B',
  primaryDark: '#97761A',
  accent: '#9B7EDE',
  accentLight: '#EDE6FB',
  grape: '#9B7EDE',
  grapeLight: '#EDE6FB',
  bg: '#FBF7F2',
  cardBg: '#FFFFFF',
  textPrimary: '#3E3355',
  textSecondary: '#7C718C',
  textHint: '#B5ACBF',
  line: '#EFE7E0',
  success: '#7FBF7F',
  warning: '#E0A458',
  danger: '#D96C8C',
  white: '#FFFFFF',
  blush: '#F7DDE8',
  gold: '#E8D9A8'
};

COLORS 常量是整个应用色彩体系的唯一数据源。primary 采用 #C9A227,这是一种饱和度适中、明度偏低的金色,传达出香槟般的奢华质感;primaryLight 提升明度至 #E3C96B,用于悬停或高亮状态;primaryDark 降至 #97761A,用于按下或阴影效果。这种三阶色阶设计使得同一色彩能够在不同交互状态下呈现层次感,而无需引入额外的色彩变量。

文本色彩的设计尤为考究。textPrimary 采用 #3E3355,这是一种带有紫色调的深灰,与 grape 主色形成同色系呼应,避免了纯黑带来的生硬感。textSecondary 为 #7C718C,明度提升约三成,用于辅助说明文本。textHint 为 #B5ACBF,明度进一步提升,用于最次要的提示性文字。三级文本色形成了清晰的视觉层级,引导用户注意力按重要性递减。

状态色的选择也经过深思熟虑。success 采用 #7FBF7F 薄荷绿,而非传统的纯绿,避免了过于刺眼的荧光感。warning 采用 #E0A458 琥珀橙,传达温和的警示意味。danger 采用 #D96C8C 玫粉,这是一种非常规的危险色选择——它放弃了传统的鲜红,转而采用带有粉调的玫红,既保留了警示功能又与整体紫系调色板和谐统一。这种设计决策体现了在"功能语义"与"美学一致性"之间寻求平衡的产品思维。

第三段:分页枚举定义

在这里插入图片描述

enum PerfumeTab {
  MATERIAL = 0,
  PERFUME = 1,
  CLASS = 2,
  SALON = 3,
  CUSTOM = 4
}

PerfumeTab 枚举定义了应用的五个分页索引,分别对应香材、香水、调香课、沙龙与定制五大业务模块。使用枚举而非魔法数字(如直接写 0、1、2、3、4)是软件工程的基本规范。枚举赋予了数字以语义,使得在条件判断分支中阅读代码时,PerfumeTab.MATERIAL 远比数字 0 更容易理解其含义。同时,枚举提供了编译期类型安全,防止开发者意外传入超出范围的值。

从架构层面看,这五个枚举值也映射了调香工作室的核心业务流程。香材是原料层,香水是产品层,调香课是教育层,沙龙是社群层,定制是高端服务层。这种从原料到产品再到服务延展的分页顺序,暗合了调香业务的价值链逻辑。用户从左到右滑动分页时,实际上是在沿着业务价值链进行浏览,这种信息架构设计本身就传递了业务理解。

显式赋值(= 0, = 1 等)虽然在不赋值时也会自动递增,但显式写出使得数值映射一目了然,便于在调试时快速定位。这种做法在需要持久化分页状态或与后端枚举对接时尤为重要,因为隐式枚举在某些编译器优化下可能产生不可预期的值。

第四段:分页元数据与列表

在这里插入图片描述

interface TabMeta {
  key: string;
  icon: string;
  label: string;
  color: string;
}

const TAB_LIST: TabMeta[] = [
  { key: 'material', icon: '🌿', label: '香材', color: '#7FBF7F' },
  { key: 'perfume', icon: '🍾', label: '香水', color: '#C9A227' },
  { key: 'class', icon: '🧪', label: '调香课', color: '#9B7EDE' },
  { key: 'salon', icon: '🥂', label: '沙龙', color: '#D96C8C' },
  { key: 'custom', icon: '💝', label: '定制', color: '#E0A458' }
];

TabMeta 接口将分页的元数据抽象为一个结构化的数据结构,包含键、图标、标签与主题色四个字段。这种"数据驱动 UI"的设计模式,使得分页栏的渲染可以通过 ForEach 遍历 TAB_LIST 数组动态生成,而非硬编码五个 Column。当未来需要新增分页时,只需在数组中添加一项并更新枚举,渲染逻辑无需修改。

每个分页都关联了独立的主题色:香材对应薄荷绿(呼应植物原料的自然感)、香水对应香槟金(呼应产品的奢华感)、调香课对应葡萄紫(呼应实验与教学的知性感)、沙龙对应玫粉(呼应社交与活动的温度感)、定制对应琥珀橙(呼应高端定制的温暖感)。这种"一页一色"的策略,使得用户在切换分页时能获得即时的色彩反馈,增强空间认知。

图标采用 Emoji 而非图标字体或 SVG 资源,这是一个值得注意的工程决策。Emoji 的优势在于零资源依赖、跨平台一致性与极快的渲染速度;劣势在于不同操作系统的 Emoji 风格可能略有差异。对于调香这类偏消费级、注重情感表达的应用,Emoji 的丰富表现力与温暖感实际上优于冷硬的线性图标,这一选择在美学与工程之间取得了恰当的平衡。

第五段:香材数据模型

在这里插入图片描述

interface MaterialItem {
  name: string;
  origin: string;
  note: string;
  stock: number;
  usage: number;
  price: number;
  emoji: string;
}

MaterialItem 接口定义了单个香材的数据结构,包含七个字段。name 记录香材名称(如"大马士革玫瑰"),origin 记录产地(如"法国"),note 记录香调分类(如"花香调"),这三个字段是香材的身份信息。stock 记录库存量(千克),usage 记录使用频率(百分比),price 记录单价(元/千克),这三个字段是运营管理信息。emoji 字段存储一个表情符号作为视觉标识。

这种数据结构设计体现了对调香业务的深刻理解。香材的产地信息在调香行业具有极高的重要性——同一种植物因产地不同,香气特征可能天差地别(如大马士革玫瑰与五月玫瑰)。将产地作为独立字段而非混入名称,使得后续可以按产地筛选或排序。note 字段将香材归入香调体系,是实现色彩映射的基础。usage 字段以百分比形式记录使用频率,可以直接转化为进度条的宽度比例,实现了数据与可视化的无缝衔接。

从类型设计角度,stock、usage、price 均为 number 类型,没有区分整数与浮点。在实际业务中,库存可能涉及小数(如 12.5kg),价格也可能存在小数(如 860.5 元),统一使用 number 避免了类型转换的麻烦。emoji 作为 string 类型存储,虽然它本质是 Unicode 字符,但在 TypeScript 中作为字符串处理是最自然的方式。

第六段:香水数据模型

在这里插入图片描述

interface PerfumeItem {
  name: string;
  series: string;
  price: number;
  ml: number;
  sales: number;
  rating: number;
  emoji: string;
}

PerfumeItem 定义了香水产品的数据结构。与 MaterialItem 相比,PerfumeItem 的字段设计反映了从"原料管理"到"产品管理"的视角转变。name 依然是产品名称,series 字段记录所属系列(如"花园系列"“高端线”),这类似于电商产品中的品类分类。price 记录零售价格,ml 记录规格容量(毫升),这两个字段共同构成产品的商业属性。sales 记录月销量,rating 记录评分,这两个字段是市场反馈指标。emoji 同样作为视觉标识。

series 字段的设计值得深入分析。在调香行业中,香水通常按系列组织,同一系列的产品在香调风格、包装设计或目标客群上具有一致性。将系列作为独立字段,使得应用可以按系列分组展示或统计。rating 字段采用浮点数(如 4.9、5.0),这在展示时需要格式化为一位小数,但存储为 number 保留了后续计算的灵活性(如计算系列平均分)。

值得注意的是,PerfumeItem 没有包含库存字段,而是以 sales(月销量)作为市场热度指标。这一设计差异体现了产品视角与原料视角的本质区别:原料关注"还剩多少"(库存),产品关注"卖了多少"(销量)。这种字段层面的差异化设计,确保了数据模型能够精准反映不同业务对象的关注点。

第七段:课程、沙龙与定制数据模型

interface ClassItem {
  name: string;
  coach: string;
  price: number;
  seats: number;
  joined: number;
  level: string;
  emoji: string;
}

interface SalonItem {
  name: string;
  theme: string;
  time: string;
  seats: number;
  joined: number;
  type: string;
  emoji: string;
}

interface CustomItem {
  name: string;
  client: string;
  price: number;
  days: number;
  stage: string;
  emoji: string;
}

这三个接口分别定义了调香课程、沙龙活动与私人定制订单的数据结构。ClassItem 包含 coach(讲师)、seats(总座位数)、joined(已报名数)与 level(级别),这些字段共同描述了课程的招生状态。SalonItem 包含 theme(主题)、time(时间)、type(类型),强调活动的时序性与主题性。CustomItem 包含 client(客户)、days(周期)、stage(阶段),聚焦于定制订单的进度追踪。

三个接口中,ClassItem 与 SalonItem 都包含 seats 与 joined 字段,这是因为课程与沙龙都涉及"容量管理"——需要追踪报名人数占总容量的比例。这种字段的复用意味着两者都可以用相同的进度条组件来展示报名率,实现了 UI 组件的跨业务复用。而 CustomItem 没有容量字段,取而代之的是 days(周期)与 stage(阶段),因为定制订单的跟踪维度是"时间进度"而非"人数进度"。

level 字段(初级、中级、高级、大师)与 stage 字段(已完成、调香中、香基调制、打样中)都是离散的字符串枚举值。在 ArkTS 中没有使用联合类型来约束这些值,而是保留为 string,这在灵活性与类型安全之间做了取舍。实际运行中,getStageColor 等工具函数会根据这些字符串值返回对应的色彩,形成"字符串到色彩"的映射逻辑。

第八段:图表数据接口定义

interface WeekMeta {
  day: string;
  value: number;
}

interface NoteMeta {
  name: string;
  count: number;
  color: string;
}

interface ClassJoinMeta {
  name: string;
  rate: number;
  color: string;
}

interface SalonHotMeta {
  name: string;
  joined: number;
  color: string;
}

这四个接口定义了应用中四种数据可视化场景的数据结构。WeekMeta 用于周销量柱状图,包含星期与数值;NoteMeta 用于香调分布,包含名称、数量与色彩;ClassJoinMeta 用于课程报名进度,包含名称、比率与色彩;SalonHotMeta 用于沙龙热度排名,包含名称、参与人数与色彩。

这些接口的共同特点是都将 color 字段内置于数据结构中。这是一种"数据自带展示属性"的设计策略,将可视化所需的色彩信息与业务数据绑定在一起。优势在于渲染时无需维护额外的色彩映射表,数据即配置;劣势在于数据与展示存在一定耦合,如果需要切换主题色方案,需要修改数据本身而非配置。

WeekMeta 没有包含 color 字段,因为周销量柱状图采用动态色彩逻辑——根据数值是否超过阈值(70)来决定使用 grape 色还是 primary 色。这种条件着色逻辑在渲染时动态计算,比预设色彩更加灵活。而其他三个接口的 color 字段都是静态预设的,因为它们的色彩对应的是业务分类(如不同香调、不同课程),这种映射是固定的。

第九段:周销量数据

const WEEK_SALES: WeekMeta[] = [
  { day: '周一', value: 36 },
  { day: '周二', value: 42 },
  { day: '周三', value: 30 },
  { day: '周四', value: 48 },
  { day: '周五', value: 65 },
  { day: '周六', value: 88 },
  { day: '周日', value: 74 }
];

WEEK_SALES 是周销量柱状图的静态数据源,包含一周七天的销量数据。从数据本身可以读出明显的业务规律:工作日销量在 30-48 之间波动,周五开始攀升至 65,周六达到峰值 88,周日回落至 74。这种"周末高峰"的模式符合消费类产品的典型销售曲线,也为运营人员安排库存与人力提供了数据参考。

将这类图表数据定义为模块级常量而非组件内部状态,是一种合理的设计。这些数据是"参考性"而非"交互性"的——用户不会修改周销量历史数据,因此无需纳入响应式状态管理。作为 const 常量,它在编译期即被确定,运行时不会触发不必要的重新渲染,性能开销最小。

数据中隐藏了一个设计细节:周六的 88 是唯一超过 70 阈值的数值。在前面的色彩逻辑中,超过 70 的柱子会使用 grape 色高亮,其余使用 primary 色。这意味着在一周的柱状图中,周六的柱子将以紫色高亮,形成视觉焦点。这种"数据驱动的视觉重点"设计,使得图表自动突出关键信息,无需额外的标注逻辑。

第十段:香调分布数据

const NOTE_SHARE: NoteMeta[] = [
  { name: '花香调', count: 2, color: '#D96C8C' },
  { name: '木质调', count: 2, color: '#9B7EDE' },
  { name: '柑橘调', count: 2, color: '#E0A458' },
  { name: '海洋调', count: 2, color: '#7FBF7F' },
  { name: '美食调', count: 2, color: '#C9A227' },
  { name: '奢华调', count: 2, color: '#E8D9A8' }
];

NOTE_SHARE 定义了六种香调的分布数据,每种香调恰好 2 款,形成均匀分布。色彩方面,花香调用玫粉、木质调用葡萄紫、柑橘调用琥珀橙、海洋调用薄荷绿、美食调用香槟金、奢华调用浅金,每种色彩都与其代表的香调在感官上形成通感联想。

这一均匀分布的设计在数据可视化层面带来一个有趣的渲染效果:所有进度条的填充比例相同(count/2 = 1.0,即满格),视觉上呈现为六条等长的彩色条。虽然这种"等比例"图表在信息量上不如差异分布丰富,但它传达了一个明确的业务信号——该工作室的香调覆盖是均衡的,没有某一类过度集中。对于品牌定位而言,这种"全谱系覆盖"本身就是一种竞争力展示。

色彩值与 COLORS 常量中的值完全一致,但这里直接使用了十六进制字面量而非引用 COLORS。这看似违背了 DRY 原则,实则是模块作用域的考量——NOTE_SHARE 作为模块级常量在 COLORS 之前或之后定义时,直接使用字面量避免了潜在的初始化顺序依赖。在更严谨的工程中,可以考虑将这类数据放入一个统一的初始化函数中生成。

第十一段:课程报名与沙龙热度数据

const CLASS_JOIN: ClassJoinMeta[] = [
  { name: '香水历史入门', rate: 92, color: '#9B7EDE' },
  { name: '周末体验班', rate: 90, color: '#C9A227' },
  { name: '儿童香水课', rate: 88, color: '#7FBF7F' },
  { name: '婚礼定制课', rate: 75, color: '#D96C8C' },
  { name: '品牌调香营', rate: 67, color: '#E0A458' }
];

const SALON_HOT: SalonHotMeta[] = [
  { name: '年度新品发布', joined: 52, color: '#C9A227' },
  { name: '七夕香氛夜', joined: 46, color: '#D96C8C' },
  { name: '春日花园品鉴', joined: 35, color: '#7FBF7F' },
  { name: '中秋桂花专场', joined: 33, color: '#E0A458' },
  { name: '古法香囊手作', joined: 28, color: '#9B7EDE' },
  { name: '香水盲测挑战', joined: 26, color: '#E8D9A8' }
];

CLASS_JOIN 与 SALON_HOT 分别定义了课程报名率与沙龙热度的排行数据。CLASS_JOIN 按报名率从高到低排列,从 92% 到 67%,覆盖了从"几乎满员"到"尚有余位"的梯度。SALON_HOT 按参与人数从多到少排列,从 52 人到 26 人,呈现了沙龙活动的热度分布。这种降序排列使得排行榜天然具有"从热到冷"的视觉阅读节奏。

数据中蕴含了丰富的业务洞察。课程方面,入门级与体验级课程报名率最高(92%、90%),说明工作室的客户获取主要依靠低门槛课程;而高端的"品牌调香营"报名率最低(67%),符合高价低量的商业逻辑。沙龙方面,"年度新品发布"以 52 人位居榜首,说明发布类活动具有最强的吸引力;而"香水盲测挑战"以 26 人殿后,可能因为活动形式较为小众。

这两个数据集在渲染时的进度条计算逻辑略有不同。CLASS_JOIN 的 rate 是百分比,直接用 rate/100 作为填充比例。SALON_HOT 的 joined 是绝对人数,需要除以最大值 52 来归一化(joined/52)。这种"相对最大值归一化"的策略,确保了进度条在数值差异巨大时仍能呈现合理的视觉比例。最大值 52 被硬编码在渲染逻辑中,这是一种简化处理——在更严谨的实现中,可以动态计算数组的最大值。

第十二段:可观察数据类——香材集合

@Observed
export class PerfumeData {
  materials: MaterialItem[] = [
    { name: '大马士革玫瑰', origin: '法国', note: '花香调', stock: 12, usage: 88, price: 860, emoji: '🥀' },
    { name: '佛手柑', origin: '意大利', note: '柑橘调', stock: 20, usage: 92, price: 320, emoji: '🍋' },
    { name: '茉莉原精', origin: '印度', note: '花香调', stock: 8, usage: 95, price: 1280, emoji: '🌼' },
    { name: '雪松木', origin: '加拿大', note: '木质调', stock: 30, usage: 78, price: 260, emoji: '🌲' },
    { name: '广藿香', origin: '印尼', note: '木质调', stock: 15, usage: 72, price: 480, emoji: '🍂' },
    { name: '香草荚', origin: '马达加斯加', note: '美食调', stock: 10, usage: 85, price: 980, emoji: '🍦' },
    { name: '白麝香', origin: '瑞士', note: '麝香调', stock: 6, usage: 90, price: 1560, emoji: '🕊️' },
    { name: '橙花', origin: '突尼斯', note: '花香调', stock: 8, usage: 82, price: 720, emoji: '🍊' },
    { name: '檀香木', origin: '印度', note: '木质调', stock: 7, usage: 76, price: 2180, emoji: '🪵' },
    { name: '黑胡椒', origin: '印度', note: '辛香调', stock: 14, usage: 68, price: 240, emoji: '🌶️' },
    { name: '依兰依兰', origin: '菲律宾', note: '花香调', stock: 11, usage: 86, price: 540, emoji: '🌺' },
    { name: '海盐', origin: '法国', note: '海洋调', stock: 18, usage: 80, price: 160, emoji: '🧂' }
  ];

PerfumeData 类使用 @Observed 装饰器标记,这是 ArkTS 响应式状态管理的核心机制。@Observed 使得类的实例在被 @State 或 @ObjectLink 引用时,其属性变更能够自动触发关联组件的重新渲染。将所有业务数据集中在一个 @Observed 类中,而非分散为多个独立状态,实现了"单一数据源"的架构目标。

materials 数组包含十二条香材数据,覆盖了花香调(玫瑰、茉莉、橙花、依兰)、柑橘调(佛手柑)、木质调(雪松、广藿香、檀香)、美食调(香草荚)、麝香调(白麝香)、辛香调(黑胡椒)与海洋调(海盐)等多种香调类型。这种全面的香材覆盖,使得应用在展示香调分布图表时能够呈现丰富的多样性。

每条数据都经过精心设计以体现真实业务场景。檀香木的单价高达 2180 元/千克且库存仅 7kg,反映了珍稀木材的稀缺性;海盐单价最低(160 元)且库存充足(18kg),是常用基础原料。usage 字段(使用频率)从 68(黑胡椒)到 95(茉莉原精),反映了不同香材在调香配方中的使用频度差异。这些真实感的数据使得应用不仅是技术展示,更像一个真实的运营工具。

第十三段:可观察数据类——香水集合

  perfumes: PerfumeItem[] = [
    { name: '晨雾玫瑰', series: '花园系列', price: 1280, ml: 50, sales: 96, rating: 4.9, emoji: '🌹' },
    { name: '海岸假日', series: '度假系列', price: 1080, ml: 50, sales: 88, rating: 4.8, emoji: '🏖️' },
    { name: '雪松之夜', series: '木质系列', price: 1580, ml: 50, sales: 82, rating: 4.9, emoji: '🌲' },
    { name: '蜜桃乌龙', series: '果香系列', price: 880, ml: 30, sales: 91, rating: 4.8, emoji: '🍑' },
    { name: '广藿之吻', series: '皮革系列', price: 1880, ml: 50, sales: 76, rating: 4.8, emoji: '🖤' },
    { name: '白茶清欢', series: '茶香系列', price: 980, ml: 50, sales: 85, rating: 4.9, emoji: '🍵' },
    { name: '星夜麝香', series: '高端线', price: 2380, ml: 50, sales: 68, rating: 5.0, emoji: '✨' },
    { name: '橙花小径', series: '花香系列', price: 780, ml: 30, sales: 89, rating: 4.7, emoji: '🍊' },
    { name: '海洋之息', series: '海洋系列', price: 1180, ml: 50, sales: 79, rating: 4.8, emoji: '🌊' },
    { name: '香草梦境', series: '美食系列', price: 880, ml: 50, sales: 87, rating: 4.8, emoji: '🍦' },
    { name: '檀道', series: '高端线', price: 3280, ml: 75, sales: 62, rating: 5.0, emoji: '🪵' },
    { name: '晨露茉莉', series: '花园系列', price: 1080, ml: 50, sales: 84, rating: 4.9, emoji: '🌼' }
  ];

perfumes 数组包含十二条香水产品数据,分布在花园系列、度假系列、木质系列、果香系列、皮革系列、茶香系列、高端线、花香系列、海洋系列与美食系列等多个产品线中。其中"高端线"包含两款(星夜麝香与檀道),价格分别为 2380 与 3280,是价格区间的最高端。

数据设计体现了清晰的价格分层策略。入门级产品如"橙花小径"(780 元/30ml)面向尝鲜客群;中端产品集中在 880-1580 元区间,是主力销售区间;高端产品超过 2000 元,定位收藏级。sales 数据与价格呈反比趋势——低价产品销量高(如橙花小径 89 瓶),高价产品销量低(如檀道 62 瓶),这符合奢侈品行业的"价格-销量"曲线规律。

rating 字段集中在 4.7-5.0 之间,整体偏高。这种设计传达了"全系列产品口碑优良"的品牌形象。值得注意的是,两款高端线产品评分均为满分 5.0,这种"高端即完美"的数据设计强化了高端产品的价值感。在实际应用中,评分数据通常来自用户评价系统,而此处作为演示数据直接预设,在真实场景中应替换为接口返回值。

第十四段:可观察数据类——课程集合

  classes: ClassItem[] = [
    { name: '香水历史入门', coach: 'Lily', price: 399, seats: 25, joined: 23, level: '初级', emoji: '📜' },
    { name: '调香基础理论', coach: 'Leo', price: 599, seats: 20, joined: 17, level: '初级', emoji: '🧪' },
    { name: '花香调进阶', coach: 'Lily', price: 899, seats: 15, joined: 12, level: '中级', emoji: '🌸' },
    { name: '木质调特训', coach: 'Leo', price: 999, seats: 12, joined: 9, level: '中级', emoji: '🌲' },
    { name: '柑橘调灵感课', coach: 'Mia', price: 699, seats: 18, joined: 15, level: '中级', emoji: '🍋' },
    { name: '商业香水拆解', coach: 'Leo', price: 1299, seats: 10, joined: 8, level: '高级', emoji: '💼' },
    { name: '沙龙香体验', coach: 'Mia', price: 1099, seats: 14, joined: 11, level: '高级', emoji: '🥂' },
    { name: '婚礼定制课', coach: 'Lily', price: 1599, seats: 8, joined: 6, level: '高级', emoji: '💍' },
    { name: '儿童香水课', coach: 'Mia', price: 499, seats: 16, joined: 14, level: '初级', emoji: '🧒' },
    { name: '香评写作课', coach: 'Lily', price: 599, seats: 20, joined: 16, level: '中级', emoji: '✍️' },
    { name: '品牌调香营', coach: 'Leo', price: 2999, seats: 6, joined: 4, level: '大师', emoji: '👑' },
    { name: '周末体验班', coach: 'Mia', price: 349, seats: 30, joined: 27, level: '初级', emoji: '🎉' }
  ];

classes 数组定义了十二门调香课程,覆盖初级、中级、高级与大师四个级别。课程价格从 349 元(周末体验班)到 2999 元(品牌调香营),跨度近九倍。seats(总座位)从 6 到 30 不等,高端课程座位少而入门课程座位多,体现了"高价小班、低价大班"的教学经济学。

三位讲师的分配也体现了业务设计。Lily 负责历史入门、花香进阶、婚礼定制与香评写作,偏向感性与人文类课程;Leo 负责基础理论、木质特训、商业拆解与品牌调香营,偏向技术与商业类课程;Mia 负责柑橘灵感、沙龙体验、儿童课程与周末体验,偏向创意与体验类课程。这种讲师与课程风格的匹配,使得每位讲师的人设鲜明,增强了应用的真实感。

joined 与 seats 的比值(报名率)揭示了课程的受欢迎程度。周末体验班以 27/30(90%)高居榜首,品牌调香营以 4/6(67%)最低。报名率与价格呈负相关趋势,但"婚礼定制课"虽价格高达 1599 元,报名率仍达 75%,说明垂直场景课程具有较强的市场吸引力。这些数据为运营人员优化课程排期与定价提供了参考依据。

第十五段:可观察数据类——沙龙集合

  salons: SalonItem[] = [
    { name: '春日花园品鉴', theme: '品鉴会', time: '07-18', seats: 40, joined: 35, type: '花香', emoji: '🏵️' },
    { name: '东方香调之旅', theme: '讲座', time: '07-26', seats: 30, joined: 24, type: '木质', emoji: '🀄' },
    { name: '调香师对谈', theme: '对谈', time: '08-03', seats: 25, joined: 21, type: '综合', emoji: '🎙️' },
    { name: '七夕香氛夜', theme: '主题夜', time: '08-10', seats: 50, joined: 46, type: '花香', emoji: '💘' },
    { name: '皮革香水私享', theme: '私享会', time: '08-17', seats: 20, joined: 15, type: '皮革', emoji: '🖤' },
    { name: '古法香囊手作', theme: '手作', time: '08-24', seats: 30, joined: 28, type: '民俗', emoji: '🧵' },
    { name: '海洋香调溯源', theme: '讲座', time: '09-01', seats: 25, joined: 19, type: '海洋', emoji: '🌊' },
    { name: '香水摄影课', theme: '课程', time: '09-08', seats: 20, joined: 14, type: '摄影', emoji: '📷' },
    { name: '中秋桂花专场', theme: '品鉴会', time: '09-15', seats: 40, joined: 33, type: '花香', emoji: '🌕' },
    { name: '红酒与香水', theme: '跨界', time: '09-22', seats: 25, joined: 20, type: '跨界', emoji: '🍷' },
    { name: '香水盲测挑战', theme: '互动', time: '09-29', seats: 30, joined: 26, type: '互动', emoji: '🎯' },
    { name: '年度新品发布', theme: '发布会', time: '10-10', seats: 60, joined: 52, type: '新品', emoji: '🎉' }
  ];

salons 数组包含十二场沙龙活动,时间从 07 月延伸至 10 月,覆盖了三个月的活动周期。活动主题涵盖品鉴会、讲座、对谈、主题夜、私享会、手作、课程与发布会等多种形式,体现了沙龙业务的内容多样性。每场活动的 time 字段以"MM-DD"格式存储,这种格式便于字符串排序与截取显示。

time 字段的处理在沙龙列表组件中展现了巧妙的字符串操作。组件通过 substring(5) 截取月日部分(如"07-18"截取为"18"),通过 substring(0, 5) 截取月份部分(保持"07-18"),分别显示为大号日期与小号月份,形成日历式的视觉呈现。这种利用字符串截取实现数据分拆显示的技术,避免了在数据模型中冗余存储拆分字段。

joined 与 seats 的比值反映了活动的报名热度。"年度新品发布"以 52/60 居首,"七夕香氛夜"以 46/50 紧随其后,说明发布类与节庆类活动具有最强的吸引力。而"香水摄影课"以 14/20 最低,可能因为形式较为小众。这些数据帮助运营人员判断哪些活动类型更受欢迎,从而优化未来的活动策划。

第十六段:可观察数据类——定制集合

  customs: CustomItem[] = [
    { name: '定制「初见」', client: '林小姐', price: 3880, days: 21, stage: '已完成', emoji: '💐' },
    { name: '定制「星语」', client: '周先生', price: 4580, days: 30, stage: '调香中', emoji: '✨' },
    { name: '定制「山岚」', client: '陈女士', price: 3280, days: 18, stage: '打样中', emoji: '🏔️' },
    { name: '定制「绯闻」', client: '王小姐', price: 2680, days: 14, stage: '已完成', emoji: '🌹' },
    { name: '定制「晨钟」', client: '刘先生', price: 5880, days: 45, stage: '香基调制', emoji: '🔔' },
    { name: '定制「晚风」', client: '赵女士', price: 3980, days: 25, stage: '已完成', emoji: '🌙' },
    { name: '定制「渔火」', client: '孙先生', price: 3480, days: 20, stage: '打样中', emoji: '🔥' },
    { name: '定制「白茶」', client: '钱小姐', price: 2980, days: 16, stage: '调香中', emoji: '🍵' },
    { name: '定制「星河」', client: '吴先生', price: 6880, days: 60, stage: '香基调制', emoji: '🌌' },
    { name: '定制「晨曦」', client: '郑女士', price: 3180, days: 19, stage: '已完成', emoji: '🌅' },
    { name: '定制「蜜语」', client: '冯小姐', price: 2780, days: 15, stage: '调香中', emoji: '🍯' },
    { name: '定制「远山」', client: '韩先生', price: 4280, days: 28, stage: '打样中', emoji: '⛰️' }
  ];
}

customs 数组包含十二条私人定制订单,每条以「诗意名称」格式命名——初见、星语、山岚、绯闻、晨钟、晚风、渔火、白茶、星河、晨曦、蜜语、远山。这些名称富有东方诗意,与调香艺术的感性气质高度契合,也传达了工作室的品牌审美。

stage 字段反映了定制订单的四个阶段:已完成(3 条)、调香中(3 条)、香基调制(2 条)、打样中(3 条)。这种分布说明工作室当前有多笔订单在并行推进,处于不同工艺阶段。stage 字段通过 getStageColor 函数映射为不同色彩——已完成对应 success 绿色、调香中对应 grape 紫色、香基调制对应 primary 金色、打样中对应 warning 橙色,形成"阶段-色彩"的语义编码。

price 与 days 的关系揭示了定制服务的定价逻辑。"星河"以 6880 元/60 天居首,是最昂贵且周期最长的订单;"绯闻"以 2680 元/14 天最低,可能是小型定制。价格与周期大致呈正相关——周期越长,投入的调香师工时越多,价格越高。这种数据关系使得运营人员可以快速评估各订单的商业价值与资源占用。

第十七段:阶段色彩映射函数

function getStageColor(stage: string): string {
  return stage === '已完成' ? COLORS.success : (stage === '调香中' ? COLORS.grape : (stage === '香基调制' ? COLORS.primary : COLORS.warning));
}

getStageColor 函数将定制订单的阶段字符串映射为对应的色彩值。这是一组嵌套的三元运算符,实现了四路分支:已完成返回 success 绿色、调香中返回 grape 紫色、香基调制返回 primary 金色、其余情况(即"打样中")返回 warning 橙色。这种"字符串到色彩"的映射是数据驱动 UI 的典型实践。

使用嵌套三元而非 switch 语句,是一种紧凑化的编码风格。在这种仅四路分支且逻辑简单的场景下,嵌套三元在一行内完成映射,代码更为紧凑。但如果分支增加到六七路以上,嵌套三元会变得难以阅读,此时 switch 或查找表(Map)更为合适。这是代码风格选择与可维护性之间的权衡。

该函数的业务语义值得品味。已完成的绿色传达"完成、安心"的信号;调香中的紫色传达"进行中、专业"的信号;香基调制的金色传达"核心工艺、珍贵"的信号;打样中的橙色传达"待定、等待"的信号。这些色彩不仅是视觉区分,更承载了阶段状态的情感语义,帮助用户在扫视列表时即时感知每笔订单的进展。

第十八段:香调色彩映射函数

function getNoteColor(note: string): string {
  return note === '花香调' ? COLORS.danger : (note === '木质调' ? COLORS.grape : (note === '柑橘调' ? COLORS.warning : (note === '海洋调' ? COLORS.success : COLORS.primary)));
}

getNoteColor 函数将香调分类字符串映射为色彩值,实现五路分支:花香调返回 danger 玫粉、木质调返回 grape 葡萄紫、柑橘调返回 warning 琥珀橙、海洋调返回 success 薄荷绿、其余返回 primary 香槟金。与 getStageColor 相比,这里多嵌套了一层三元,共四层嵌套。

色彩与香调的映射并非随意,而是基于"通感"的设计原则。花香调用玫粉,因为花朵常与粉红色关联;木质调用紫色,呼应木质调的沉稳深邃;柑橘调用橙色,直接对应柑橘的果皮颜色;海洋调用绿色,呼应海洋的清新感;其余(如美食调、麝香调)用金色,传达奢华感。这种"色彩-感官"的跨模态映射,使得用户在看到色彩时能本能地联想到对应的香调体验。

该函数在香材列表组件中被多次调用——既用于左侧的色彩指示条,也用于 PerfumeTag 标签的文本与背景色,还用于进度条的色彩。同一种香调用同一色彩贯穿列表项的多个视觉元素,形成了"色彩一致性"的视觉编码体系。这种一致性帮助用户在长列表中快速识别同调性香材,提升了信息检索效率。

第十九段:主入口组件状态声明

@Entry
@Component
struct PerfumeApp {
  @State curTab: number = 0;
  @State data: PerfumeData = new PerfumeData();
  @State showAddMaterial: boolean = false;
  @State showEditPerfume: boolean = false;
  @State showDeleteSalon: boolean = false;
  @State showDetail: boolean = false;
  @State delSalonName: string = '';
  @State detailCustom: string = '';
  @State petalFall: boolean = false;
  @State tubeShake: boolean = false;

PerfumeApp 是应用的入口组件,使用 @Entry 与 @Component 装饰器标记。@Entry 表示这是页面的根组件,编译器会为其生成页面入口代码;@Component 表示这是一个自定义组件,可以在其他组件中被引用。这两个装饰器的组合是 ArkTS 页面入口的标准写法。

九个 @State 状态变量管理着应用的全部交互状态。curTab 追踪当前选中的分页索引(0-4);data 是 PerfumeData 的实例,承载所有业务数据。四个 show 开头的布尔值分别控制四个弹窗的显示与隐藏。delSalonName 与 detailCustom 是传递给弹窗的参数,记录被操作的沙龙名称或定制订单名称。petalFall 与 tubeShake 是顶栏装饰性交互的动画状态。

将所有交互状态集中管理在入口组件中,是"状态上提"的设计策略。子组件通过回调函数通知父组件状态变更,父组件通过参数将新状态传递给子组件。这种单向数据流确保了状态的可追踪性,所有状态变更都经由入口组件中转。虽然增加了少量的事件传递代码,但换来了清晰的状态所有权与可维护性。data 使用 @State 而非 @Provide,意味着只有 PerfumeApp 及其直接子组件能访问数据,这在当前组件层级是足够的,但如果未来组件层级加深,可能需要升级为 @Provide/@Consume。

第二十段:模态遮罩构建器

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
    }
    .width(1)
    .height(1)
  }

modalOverlay 是一个 @Builder 方法,用于构建模态遮罩层。@Builder 是 ArkTS 中的可复用 UI 片段定义方式,类似于其他框架中的"渲染函数"或"模板片段"。该构建器接收一个 onClose 回调函数作为参数,当遮罩被点击时触发关闭逻辑。

这段代码的设计相当精巧。遮罩层本身是一个 1x1 像素的透明容器,内部包含一个零尺寸的 Text 和一个 1x1 像素的透明 Button。Button 的点击事件调用 onClose 回调。这种"隐形但可点击"的设计,实际上是一个事件捕获层——它覆盖在弹窗下方,当用户点击弹窗外部区域时触发关闭。由于容器尺寸仅为 1x1 像素且透明度为 0,它不会影响弹窗本身的视觉表现。

使用 @Builder 而非独立 @Component 来定义遮罩,是因为遮罩逻辑极为简单且无需独立状态管理。@Builder 的开销低于完整组件,适合这种轻量级的 UI 片段复用。不过在实际的弹窗组件中,遮罩逻辑被直接嵌入而非通过 modalOverlay 引用,说明该构建器更多是作为设计预案保留。这种"预留扩展点"的编码习惯,体现了开发者对架构演化的前瞻性思考。

第二十一段:主入口构建——顶栏标题区

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('🧪 香水调香室')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('Perfume Atelier · 匠心调香')
                .fontSize(10)
                .fontColor('#FFFFFFCC')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

build 方法是组件的核心,定义了整个应用的 UI 结构。最外层是一个 Column,内部嵌套 Column(顶栏+内容区)、Row(分页栏)以及条件渲染的弹窗组件。这种"纵向三段式"布局——顶栏、内容区、底栏——是移动端应用的经典信息架构。

顶栏内部的标题区使用了 Column 左对齐,包含主标题与副标题。主标题"🧪 香水调香室"采用 20 号粗体白色字体,副标题"Perfume Atelier · 匠心调香"采用 10 号半透明白色(#FFFFFFCC,CC 为 Alpha 通道 80% 不透明度)。主副标题的字号差异(20:10)与色彩差异(纯白:半透明白)形成了清晰的视觉层级,主标题抓取注意力,副标题补充品牌信息。

layoutWeight(1) 使得标题 Column 占据顶栏的剩余空间,将右侧的交互按钮推向行尾。这是 Flex 布局中"弹性占位"的典型用法——通过给一个子元素设置 layoutWeight(1),其余元素按自然尺寸排列,实现了"标题左对齐、按钮右对齐"的经典导航栏布局。alignItems(HorizontalAlign.Start) 确保标题文本左对齐而非默认居中。

第二十二段:主入口构建——顶栏交互按钮

            Row() {
              Text('🌸')
                .fontSize(20)
                .onClick(() => {
                  this.petalFall = !this.petalFall;
                })
                .translate({ y: this.petalFall ? 10 : 0 })
                .rotate({ angle: this.petalFall ? 30 : 0 })
                .animation({ duration: 600, curve: Curve.EaseOut })
              Text('🧪')
                .fontSize(16)
                .margin({ left: 10 })
                .onClick(() => {
                  this.tubeShake = !this.tubeShake;
                })
                .rotate({ angle: this.tubeShake ? -15 : 0 })
                .animation({ duration: 400, curve: Curve.EaseOut })
              Text('🥂')
                .fontSize(16)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor('#FFFFFF1F')
            .borderRadius(18)
            .border({ width: 1, color: '#FFFFFF40' })

顶栏右侧是一个 Row 容器,包含三个 Emoji 文本元素,分别代表花朵、试管与酒杯。前两个 Emoji 绑定了点击事件,触发 petalFall 与 tubeShake 状态的切换。点击花朵时,petalFall 在 true/false 之间翻转,触发 translate(位移)与 rotate(旋转)的动画过渡——花瓣下移 10 像素并旋转 30 度。点击试管时,tubeShake 翻转触发旋转 -15 度的摇摆动画。

这是应用中"情感化交互"的典型实现。@State 变量的布尔翻转驱动动画属性的变化,animation 装饰器自动插值过渡。花朵的动画时长 600 毫秒、曲线 EaseOut(先快后慢),模拟花瓣飘落的自然运动;试管的动画时长 400 毫秒,模拟试管摇晃的快速回弹。这种"状态驱动动画"的模式是 ArkTS 声明式动画的核心——开发者只需描述终态与动画参数,框架自动处理中间帧。

按钮容器采用半透明白色背景(#FFFFFF1F,约 12% 不透明度)与圆角 18 的药丸造型,边框为 1 像素半透明白色(#FFFFFF40,25% 不透明度)。这种"玻璃拟态"(Glassmorphism)风格的容器,在紫色顶栏背景上呈现出一层半透明玻璃质感,既界定了按钮组的边界,又不喧宾夺主。第三个酒杯 Emoji 没有绑定交互,作为装饰性元素丰富按钮组的视觉层次。

第二十三段:主入口构建——色彩标签与顶栏背景

          Row() {
            Text('香槟金')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
            Text('雾紫')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
            Text('玫瑰粉')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.grape)

顶栏第二行是三个色彩名称标签:“香槟金”“雾紫”“玫瑰粉”,分别对应应用调色板中的 primary、grape 与 danger 色彩。每个标签采用 8 号半透明白色字体,背景为更深一层的半透明白色(#FFFFFF2E,约 18% 不透明度),圆角 8 的紧凑药丸造型。这种设计既是色彩体系的自展示,也作为顶栏的副信息层,丰富视觉层次。

三个标签的代码高度重复,这是为了保持代码的直观性而有意为之。在更工程化的实现中,可以将这三个标签提取为一个 ForEach 遍历,通过数据数组驱动渲染。但当前实现以"可读性优先"的策略,将每个标签显式展开,使得设计师在审阅代码时能直接对应每个标签的视觉参数,便于微调。

顶栏整体使用 COLORS.grape(#9B7EDE 葡萄紫)作为背景色,padding 为左右 16、上下 12。这个紫色顶栏是整个应用的视觉锚点,与底部白色分页栏形成强烈的明暗对比,界定了"标题区"与"操作区"的空间边界。顶栏的 Column 嵌套结构(外层 Column 包含标题 Row 与标签 Row)确保了两行内容的纵向排列与统一间距。

第二十四段:主入口构建——分页内容切换

        if (this.curTab === PerfumeTab.MATERIAL) {
          MaterialContent({ data: this.data, onAdd: () => {
            this.showAddMaterial = true;
          } })
        } else if (this.curTab === PerfumeTab.PERFUME) {
          PerfumeContent({ data: this.data, onEdit: () => {
            this.showEditPerfume = true;
          } })
        } else if (this.curTab === PerfumeTab.CLASS) {
          ClassContent({ data: this.data })
        } else if (this.curTab === PerfumeTab.SALON) {
          SalonContent({ data: this.data, onDel: (n: string) => {
            this.delSalonName = n;
            this.showDeleteSalon = true;
          } })
        } else {
          CustomContent({ data: this.data, onDetail: (n: string) => {
            this.detailCustom = n;
            this.showDetail = true;
          } })
        }

这段代码实现了分页内容区的条件渲染逻辑。根据 curTab 的值,渲染对应的 Content 组件。每个内容组件接收 data 参数(PerfumeData 实例)以及一个或多个回调函数。当用户在子组件中触发操作(如点击新增、编辑、删除、查看详情)时,回调函数被调用,在父组件中设置对应的状态变量,触发弹窗的显示。

这种"回调冒泡"的跨组件通信模式是 ArkTS 中极为重要的设计范式。子组件不直接管理弹窗状态(因为弹窗挂在父组件层级),而是通过回调函数通知父组件"我需要打开某个弹窗",由父组件统一调度。这保证了弹窗的挂载位置始终在入口组件层级,覆盖全屏,不会被其他组件遮挡。

每个内容组件的回调签名各不相同。MaterialContent 接收 onAdd(无参),PerfumeContent 接收 onEdit(无参),SalonContent 接收 onDel(带名称参数),CustomContent 接收 onDetail(带名称参数)。带参数的回调传递了"操作目标"信息(如要删除哪个沙龙、查看哪个定制),使得父组件能够将该信息传递给弹窗组件。ClassContent 没有回调,说明课程模块当前没有弹窗交互,是纯展示页面。这种差异化的回调设计,精确匹配了各业务模块的交互需求。

第二十五段:主入口构建——分页栏渲染

      Row() {
        ForEach(TAB_LIST, (t: TabMeta) => {
          Column() {
            Text(t.icon)
              .fontSize(19)
            Text(t.label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_LIST.indexOf(t) ? t.color : COLORS.textHint)
              .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.gold : COLORS.cardBg)
          .borderRadius(16)
          .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '55' } : { width: 1, color: COLORS.line })
          .onClick(() => {
            this.curTab = TAB_LIST.indexOf(t);
          })
        }, (t: TabMeta) => t.key)
      }
      .width('100%')
      .height(60)
      .padding({ left: 8, right: 8 })
      .backgroundColor(COLORS.cardBg)
      .border({ width: 1, color: COLORS.line })

分页栏通过 ForEach 遍历 TAB_LIST 数组动态生成五个分页按钮。每个按钮是一个 Column,包含图标(19 号)与标签文字(10 号)。按钮的视觉状态根据是否为当前选中分页(curTab === indexOf(t))进行差异化渲染:选中时标签文字使用该分页的主题色并加粗,背景为 COLORS.gold 浅金色,边框为半透明主题色(t.color + ‘55’);未选中时标签使用 textHint 灰色,背景为白色,边框为 line 浅灰色。

ForEach 的第三个参数是键生成函数 (t: TabMeta) => t.key,用于列表项的唯一标识。ArkTS 的 ForEach 通过键来追踪列表项的增删改,确保 Diff 算法正确工作。使用 key 字段(如 ‘material’、‘perfume’)作为唯一标识,保证列表项在数据变化时能正确复用而非全部重建,提升渲染性能。

分页栏整体高度 60 像素,内边距左右 8,背景为白色卡片色,带 1 像素浅灰边框。选中按钮的圆角 16 形成药丸造型,与未选中按钮的圆角形成统一的圆角语言。onClick 回调将 curTab 设置为被点击项的索引,触发条件渲染切换内容区。整个交互链路——点击 → 状态变更 → 条件渲染 → 内容切换——在 ArkTS 的声明式框架中自动完成,开发者只需声明状态与 UI 的映射关系。

第二十六段:主入口构建——弹窗条件渲染

      if (this.showAddMaterial) {
        AddMaterialModal({
          onClose: () => {
            this.showAddMaterial = false;
          }
        })
      }
      if (this.showEditPerfume) {
        EditPerfumeModal({
          onClose: () => {
            this.showEditPerfume = false;
          }
        })
      }
      if (this.showDeleteSalon) {
        DeleteSalonModal({
          title: this.delSalonName, onClose: () => {
            this.showDeleteSalon = false;
          }
        })
      }
      if (this.showDetail) {
        DetailCustomModal({
          name: this.detailCustom, onClose: () => {
            this.showDetail = false;
          }
        })
      }

四个弹窗组件通过条件渲染按需挂载。每个 if 语句检查对应的 show 布尔状态,为 true 时渲染弹窗组件。每个弹窗组件都接收一个 onClose 回调函数,在弹窗内部被调用时将对应的 show 状态设为 false,触发条件渲染卸载弹窗。DeleteSalonModal 额外接收 title 参数,DetailCustomModal 额外接收 name 参数,这些参数在子组件触发操作时由父组件设置。

这种"条件渲染弹窗"的模式,比使用 Stack 叠加层管理弹窗更为简洁。弹窗组件只在需要时才被创建,不需要时完全不存在于组件树中,避免了不必要的内存占用与渲染开销。代价是弹窗的挂载/卸载是"硬切换"——没有淡入淡出动画。如果需要动画效果,可以考虑使用 transition 属性或改用 BindSheet 等系统弹窗组件。

onClose 回调的统一设计,使得弹窗组件内部的关闭逻辑(如点击取消按钮、点击关闭图标)都汇聚到同一个回调函数,由父组件统一管理状态。这种"接口约定一致"的设计,使得四个弹窗组件的行为模式可预测,降低了维护成本。如果未来需要添加关闭动画或确认逻辑,只需在 onClose 回调中统一修改,所有弹窗同步生效。

第二十七段:通用标签组件

@Component
struct PerfumeTag {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .backgroundColor(this.color + '1F')
      .borderRadius(6)
      .border({ width: 1, color: this.color + '40' })
  }
}

PerfumeTag 是一个通用的标签组件,使用 @Prop 接收 text 与 color 两个参数。@Prop 是单向数据传递装饰器,父组件传递的数据在子组件中只读,子组件不能修改 @Prop 变量。这与 @ObjectLink 的双向绑定不同,适用于纯展示型组件。PerfumeTag 在五个内容页面中被广泛使用,用于展示香调、系列、级别、类型、阶段等分类标签。

标签的视觉设计遵循"色彩自引用"策略。文本颜色为传入的 color,背景为 color + ‘1F’(12.5% 不透明度的同色),边框为 color + ‘40’(25% 不透明度的同色)。这种通过十六进制 Alpha 通道后缀实现"同色不同透明度"的技术,是 ArkTS 中极为实用的色彩变体生成方法——无需额外定义浅色变量,直接在运行时拼接字符串即可生成半透明色。

PerfumeTag 的复用价值极高。在香材列表中,它用于展示香调分类;在香水列表中,展示系列分类;在课程列表中,展示级别;在沙龙列表中,展示类型;在定制列表中,展示阶段。同一个组件通过传入不同的 text 与 color,呈现出五种不同的语义标签。这种"高复用、低耦合"的组件设计,是组件化架构的核心价值——一次编写,处处复用,统一维护。

第二十八段:香材内容页——周销量柱状图标题

@Component
struct MaterialContent {
  @ObjectLink data: PerfumeData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 本周销量(瓶)')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六峰值')
              .fontSize(9)
              .fontColor(COLORS.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)

MaterialContent 是香材分页的内容组件,使用 @ObjectLink 接收 PerfumeData 实例。@ObjectLink 建立了子组件与父组件 @State 数据的双向绑定——当父组件的 data 属性变化时,子组件自动重新渲染;子组件修改 data 的属性(如向 materials 数组添加项),父组件也能感知。onAdd 是一个回调函数,默认值为空函数,在点击新增按钮时被调用。

整个内容区包裹在 Scroll 中,确保内容超出屏幕时可以滚动。内部 Column 是垂直排列的卡片容器。第一个卡片是周销量柱状图,标题行使用 Row 的 SpaceBetween 布局——左侧标题"📈 本周销量(瓶)"靠左,右侧标注"周六峰值"靠右。右侧标注使用 danger 玫粉色,强调峰值信息。

标题区的设计体现了"标题+注释"的信息分层策略。13 号粗体主标题传达图表主题,9 号辅助标注传达关键数据洞察。将"周六峰值"直接标注在标题行,使得用户在浏览图表前就获得了关键结论,降低了数据解读的认知成本。这种"结论先行、数据佐证"的展示策略,在运营管理类应用中尤为有效。

第二十九段:香材内容页——周销量柱状图渲染

          Row() {
            ForEach(WEEK_SALES, (w: WeekMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 70 ? COLORS.grape : COLORS.textSecondary)
                Column()
                  .width(20)
                  .height(w.value * 1.05)
                  .backgroundColor(w.value >= 70 ? COLORS.grape : COLORS.primary)
                  .borderRadius(4)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekMeta) => w.day)
          }
          .width('100%')
          .height(140)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })

柱状图通过 ForEach 遍历 WEEK_SALES 数组,为每一天渲染一个 Column 柱子。每个柱子包含三部分:顶部的数值文本(8 号)、中间的彩色柱体(宽度 20、高度为 value1.05)、底部的星期标签(9 号)。柱体高度乘以 1.05 的系数,是为了让最高值(88)的柱子不超过容器高度(140 * 1.05 ≈ 92.4 像素,881.05=92.4)刚好合适。

柱体的色彩采用条件逻辑:value >= 70 时使用 grape 紫色高亮,否则使用 primary 金色。这种"阈值着色"策略自动突出销量高峰(周六 88 与周日 74),引导用户关注关键数据点。数值文本的色彩与柱体保持同步高亮逻辑,形成色彩一致性。整个 Row 容器设置 alignItems(VerticalAlign.Bottom),使得所有柱子底部对齐,从下往上生长,符合柱状图的标准视觉规范。

layoutWeight(1) 使得七个柱子等宽分布,每个占 1/7 的宽度。alignItems(HorizontalAlign.Center) 确保柱子内部的数值、柱体与标签水平居中对齐。这种"等宽列+内部居中"的布局是柱状图渲染的基础模式。柱体的 borderRadius(4) 使得柱子顶部带有圆角,柔化了纯方形的视觉生硬感,与整体的圆角设计语言一致。

第三十段:香材内容页——香调分布图标题

        Column() {
          Row() {
            Text('🌸 香调分布')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('全系列12款')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)

第二个卡片是香调分布图,标题行同样采用 SpaceBetween 布局。左侧"🌸 香调分布"为主标题,右侧"全系列12款"为辅助标注。与周销量标题不同的是,这里的辅助标注使用 textHint 灰色而非 danger 玫粉,因为"全系列12款"是中性信息而非警示信息。

这种"标题+标注"的色彩语义差异,体现了对信息层级的精细把控。警示性标注使用 danger 色,吸引注意力;中性标注使用 textHint 色,低调补充。用户在扫视标题行时,色彩差异帮助其快速区分"需要关注"与"仅供参考"的信息,提升信息筛选效率。

该卡片的标题行结构与周销量卡片完全一致——相同的 Row、SpaceBetween、fontSize 比例、margin 间距。这种跨卡片的结构一致性,是"卡片化设计系统"的体现:所有卡片遵循统一的标题区结构,差异仅在内容区。这种一致性使得用户在浏览多个卡片时,能快速适应每个卡片的阅读节奏,降低认知切换成本。

第三十一段:香材内容页——香调分布进度条

          ForEach(NOTE_SHARE, (n: NoteMeta) => {
            Row() {
              Text(n.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(60)
              Row() {
                Row()
                  .layoutWeight(n.count / 2)
                  .height(8)
                  .backgroundColor(n.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1 - n.count / 2)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .height(8)
              Text(n.count + '款')
                .fontSize(9)
                .fontColor(n.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (n: NoteMeta) => n.name)

香调分布图通过 ForEach 遍历 NOTE_SHARE 数组,为每种香调渲染一行进度条。每行包含三部分:左侧香调名称(固定宽度 60)、中间的进度条(layoutWeight(1) 弹性占位)、右侧数量标注(固定宽度 32,右对齐)。进度条本身由两个 Row 组成——填充部分使用香调对应的色彩,宽度比例为 count/2;未填充部分使用背景色,宽度比例为 1-count/2。

这种"双 Row 拼接进度条"的技术是 ArkTS 中实现水平进度条的经典模式。两个 Row 的 layoutWeight 之和为 1(count/2 + (1-count/2)),确保进度条总宽度不变。填充部分的 layoutWeight 随 count 值变化,实现了"数据驱动宽度"的动态渲染。由于 NOTE_SHARE 中所有 count 均为 2,所有进度条都呈现为满格(count/2=1,1-count/2=0),视觉上是一组等长的彩色条。

右侧的数量标注"2款"使用香调对应的色彩,与进度条填充色一致,形成"色彩-数字"的关联编码。这种关联使得用户在看到彩色进度条时,能立即对应到右侧的数字标注,无需反复扫视。进度条高度 8 像素、圆角 4,与整体设计中所有进度条的尺寸保持一致,形成了统一的进度条设计规范。

第三十二段:香材内容页——库存列表标题

        Column() {
          Row() {
            Text('🌿 香材库存')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕采购')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })

第三个卡片是香材库存列表,标题行与前面两个卡片的结构类似但增加了一个可点击的"➕"按钮。这个按钮绑定了 onAdd 回调,点击时触发父组件打开新增香材弹窗。这是应用中"标题行内嵌操作"的设计模式——将新增入口直接放在列表标题行,用户无需寻找独立的新增按钮,降低了操作路径。

"点击➕采购"的提示文字使用 textHint 灰色,低调引导用户发现操作入口。这种"提示+按钮"的组合设计,既保留了操作的可发现性,又不喧宾夺主。当用户熟悉操作后,提示文字的灰色使其自然融入背景,按钮成为视觉焦点。

标题行的 SpaceBetween 布局使得三部分内容自然分布:主标题靠左、提示文字居中偏右、新增按钮靠右。这种布局利用了 Row 的弹性空间分配,无需手动设置 margin 或 padding,实现了自适应的内容分布。margin({ bottom: 10 }) 在标题行与列表内容之间留出 10 像素的间距,界定了标题区与内容区的视觉边界。

第三十三段:香材内容页——库存列表项渲染

          ForEach(this.data.materials, (m: MaterialItem, i: number) => {
            Row() {
              Column()
                .width(6)
                .height(38)
                .backgroundColor(getNoteColor(m.note))
                .borderRadius(3)
              Text(m.emoji)
                .fontSize(18)
                .width(36)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(m.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: m.note, color: getNoteColor(m.note) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('产地 ' + m.origin + ' · 库存 ' + m.stock + 'kg')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Row() {
                  Row()
                    .layoutWeight(m.usage / 100)
                    .height(5)
                    .backgroundColor(m.usage >= 90 ? COLORS.danger : COLORS.primary)
                    .layoutWeight(1 - m.usage / 100)
                    .height(5)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(2)
                }
                .width('100%')
                .height(5)
                .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })

每个香材列表项是一个 Row,包含四个部分:左侧色彩指示条(宽 6、高 38)、Emoji 图标(宽 36)、中间信息区(layoutWeight(1) 弹性占位)以及右侧价格区。色彩指示条使用 getNoteColor 函数根据香调返回对应色彩,使得用户在扫视列表时能通过色彩条快速识别香调分类。这种"色彩编码+色彩条"的双重编码策略,大大提升了长列表的可读性。

中间信息区包含三行内容:香材名称与香调标签(同一 Row)、产地与库存信息、使用频率进度条。名称使用 13 号粗体,标签使用 PerfumeTag 组件以香调色展示。产地与库存拼接为一段文本"产地 法国 · 库存 12kg",使用中点分隔符 · 实现信息的紧凑展示。使用频率进度条与前面的香调分布进度条技术一致——双 Row 拼接,layoutWeight 按 usage/100 比例分配。

进度条的色彩采用条件逻辑:usage >= 90 时使用 danger 玫粉色高亮,否则使用 primary 金色。这种高亮策略将"高频使用"的香材标红,提醒运营人员关注其库存消耗速度。当某香材使用频率超过 90% 且库存较低时,运营人员应优先安排补货。这种"数据驱动色彩+色彩驱动决策"的设计,使得 UI 不仅展示数据,更辅助业务决策。

第三十四段:香材内容页——列表项右侧与容器

              Column() {
                Text('¥' + m.price)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.grape)
                Text('用量' + m.usage)
                  .fontSize(9)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (m: MaterialItem, i: number) => m.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.blush + '66')
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })
        .margin({ top: 12 })

列表项右侧是一个 Column,展示价格与用量两个信息。价格使用 grape 紫色粗体,是列表项中唯一使用紫色的元素——因为紫色是该应用中代表"价值"的色彩语义。用量标注使用 textHint 灰色,作为价格的辅助信息。Column 的 alignItems(End) 使得两个文本右对齐,与左侧信息区的左对齐形成对称的视觉布局。

列表项容器的样式遵循统一的卡片规范:白色背景、圆角 10、1 像素浅灰边框、bottom margin 8。这些参数在所有列表项中保持一致,形成了统一的"列表项设计语言"。ForEach 的键生成函数使用 m.name + i(名称加索引),确保即使存在同名香材,键也是唯一的。索引 i 的加入是防御性设计——当数据中存在重名项时,避免键冲突导致的渲染异常。

外层卡片容器使用 blush + ‘66’(40% 不透明度的腮红粉)作为背景色,这是一种极淡的粉色,与白色列表项形成微妙的色温差异,使得列表项在粉色背景上"浮"出来。边框使用 gold 浅金色,与周销量卡片的 gold 边框一致,形成了"数据卡片金边"的设计语言。margin({ top: 12 }) 与前一个卡片保持间距。

第三十五段:香水内容页——产品列表标题

@Component
struct PerfumeContent {
  @ObjectLink data: PerfumeData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🍾 香水系列')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✎调价')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })

PerfumeContent 是香水分页的内容组件,结构与 MaterialContent 类似——@ObjectLink 接收 data,声明 onEdit 回调用于触发编辑弹窗。整个内容区包裹在 Scroll 中,内部是垂直排列的卡片。第一个卡片是香水产品列表,标题行"🍾 香水系列"与辅助标注"点击✎调价",布局与香材库存列表的标题行一致。

"点击✎调价"的提示文字引导用户发现编辑入口。与香材列表的"点击➕采购"相比,两者结构一致——都是"操作引导文字+操作图标"的组合,但操作图标没有直接放在标题行,而是嵌入在每个产品列表项的右侧。这种设计差异是因为"新增香材"是全局操作(与具体香材无关),放在标题行更合理;而"调价"是针对具体产品的操作,放在每个产品项内更符合操作上下文。

标题行的设计保持了与其他内容页面的一致性——相同的 fontSize 比例、相同的 SpaceBetween 布局、相同的 margin 间距。这种跨页面的一致性是应用整体设计语言统一的关键。用户在切换分页时,标题行的结构完全一致,差异仅在内容,降低了适应成本。

第三十六段:香水内容页——产品列表项渲染

          ForEach(this.data.perfumes, (p: PerfumeItem, i: number) => {
            Row() {
              Text(p.emoji)
                .fontSize(20)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.blush)
                .borderRadius(21)
              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: p.series, color: COLORS.grape })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('规格 ' + p.ml + 'ml · 月销 ' + p.sales + ' 瓶')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 10 })
              Column() {
                Text('¥' + p.price)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.danger)
                Text('⭐' + p.rating)
                  .fontSize(9)
                  .fontColor(COLORS.primary)
                  .margin({ top: 3 })
                Text('✎')
                  .fontSize(12)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 3 })
                  .onClick(() => {
                    this.onEdit();
                  })
              }
              .alignItems(HorizontalAlign.End)

每个香水列表项是一个 Row,包含三部分:左侧圆形 Emoji 图标、中间信息区、右侧操作区。Emoji 图标使用 blush 腮红粉色圆形背景(宽高 42、borderRadius 21),形成药丸状的圆形头像效果。这种"Emoji 圆形头像"的设计,为每个产品赋予了视觉标识,同时圆形背景使得 Emoji 的呈现更加规范统一。

中间信息区包含产品名称(13 号粗体)与系列标签(PerfumeTag,使用 grape 紫色),以及规格与月销信息。系列标签使用统一的 grape 紫色而非不同系列不同色,这是为了保持列表的视觉简洁——如果每个系列用不同色,十二条产品的标签将出现多种颜色,增加视觉噪音。统一的紫色标签使得标签层成为"安静"的辅助信息层,不与价格等关键信息争夺注意力。

右侧操作区展示三个信息:价格(12 号粗体 danger 玫粉色)、评分(9 号 primary 金色带星号前缀)、编辑图标(✎,textHint 灰色)。价格使用 danger 玫粉色是应用中"价格信息"的统一色彩规范——在课程列表与定制列表中,价格同样使用 danger 色。编辑图标绑定 onEdit 回调,点击时触发父组件打开编辑弹窗。✎ 图标使用 textHint 灰色,低调但可点击,是"轻量级操作入口"的设计选择。

第三十七段:香水内容页——调香小课堂

        Column() {
          Row() {
            Text('💡 调香小课堂')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('本周更新')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 8 })
          Row() {
            Text('🌹')
              .fontSize(16)
            Text('前调留香约 15 分钟,柑橘类香材优先挥发')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ left: 8 })
              .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .margin({ bottom: 8 })
          Row() {
            Text('🌲')
              .fontSize(16)
            Text('木质后调可持香 6-8 小时,适合秋冬使用')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ left: 8 })
              .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .margin({ bottom: 8 })
          Row() {
            Text('🧪')
              .fontSize(16)
            Text('酒精浓度 15%-20% 的淡香水留香约 3-4 小时')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ left: 8 })
              .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
        }

第二个卡片是"调香小课堂",展示三条调香知识。每条知识是一个 Row,左侧 Emoji 图标(16 号),右侧知识文本(10 号 textSecondary 灰色)。三条知识分别涉及前调留香时间、木质后调持香时长与酒精浓度对应留香时长,涵盖了调香的基础知识体系。

这种"知识卡片"的设计,使得应用不仅是管理工具,更承载了教育功能。对于调香工作室的客户而言,在浏览产品的同时获取调香知识,提升了应用的内容价值与用户黏性。"本周更新"的标注暗示这些知识会定期轮换,鼓励用户持续打开应用。

三条知识的 Row 结构完全一致——相同的 padding(10)、backgroundColor(bg)、borderRadius(10)、margin({ bottom: 8 })。这种结构一致性使得三条知识在视觉上形成均匀的节奏感,用户阅读时如同浏览一组整齐的知识卡片。如果未来需要从接口动态加载知识内容,可以将这三条 Row 改为 ForEach 遍历,结构无需调整。

第三十八段:课程内容页——课程列表标题

@Component
struct ClassContent {
  @ObjectLink data: PerfumeData;

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🧪 调香课程')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('预约制小班')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })

ClassContent 是课程分页的内容组件,仅接收 @ObjectLink data 参数,没有回调函数。这意味着课程模块当前是纯展示页面,没有弹窗交互。这种设计可能是因为课程管理的操作(如报名、取消)在真实场景中通常跳转至独立页面,而非在当前页面弹窗处理。

标题行"🧪 调香课程"与"预约制小班"的标注,传达了课程的两个核心属性:主题是调香教学,形式是预约制小班。"预约制小班"的标注使用 textHint 灰色,低调提示课程形式,帮助用户在浏览前建立预期。这种在标题中嵌入业务属性标注的设计,提升了信息的传递效率。

ClassContent 的结构与 MaterialContent 和 PerfumeContent 保持一致——Scroll 包裹 Column,内部是卡片化的内容组织。这种跨页面的一致性,使得用户在切换分页时,内容区的滚动行为、卡片样式与标题结构完全一致,体验流畅自然。即使各页面的具体内容差异很大,统一的框架结构降低了学习成本。

第三十九段:课程内容页——课程列表项与报名进度

          ForEach(this.data.classes, (c: ClassItem, i: number) => {
            Row() {
              Text(c.emoji)
                .fontSize(20)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.grapeLight)
                .borderRadius(12)
              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: c.level, color: COLORS.grape })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('讲师 ' + c.coach + ' · 报名 ' + c.joined + '/' + c.seats)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Row() {
                  Row()
                    .layoutWeight(c.joined / c.seats)
                    .height(5)
                    .backgroundColor(c.joined === c.seats ? COLORS.danger : COLORS.grape)
                    .borderRadius(2)
                  Row()
                    .layoutWeight(1 - c.joined / c.seats)
                    .height(5)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(2)
                }
                .width('100%')
                .height(5)
                .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 10 })
              Column() {
                Text('¥' + c.price)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.danger)
                Text(c.joined === c.seats ? '已满员' : '有名额')
                  .fontSize(9)
                  .fontColor(c.joined === c.seats ? COLORS.danger : COLORS.success)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)

课程列表项的结构与香水列表项类似,但有几处关键差异。Emoji 图标背景使用 grapeLight 浅紫色而非 blush 粉色,圆角为 12(方形圆角)而非 21(圆形),这是因为课程图标更偏向"标签"而非"头像"的视觉定位。级别标签使用 PerfumeTag 统一以 grape 紫色展示,与香水系列的标签色彩一致。

中间信息区的第三行是报名进度条,其填充比例为 joined/seats(已报名/总座位)。进度条色彩采用条件逻辑:已满员(joined === seats)时使用 danger 玫粉色,未满时使用 grape 紫色。这种"满员标红"的策略,使得满员课程在列表中一目了然,帮助用户快速识别哪些课程还能报名。进度条右侧的状态文字(“已满员"或"有名额”)进一步强化了这一信息,形成了进度条与文字的双重编码。

右侧操作区展示价格与名额状态。价格使用 danger 玫粉色(与所有列表项的价格色彩一致),名额状态使用条件色彩——满员用 danger、有名额用 success。这种"状态语义化色彩"使得用户扫视右侧时,红色意味着"不可报名",绿色意味着"可报名",色彩直接传达了可操作性,降低了决策成本。

第四十段:课程内容页——报名进度排行

        Column() {
          Row() {
            Text('🎟️ 报名进度')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('热门课程')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(CLASS_JOIN, (c: ClassJoinMeta) => {
            Row() {
              Text(c.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .layoutWeight(1)
              Row() {
                Row()
                  .layoutWeight(c.rate / 100)
                  .height(7)
                  .backgroundColor(c.color)
                  .borderRadius(3)
                Row()
                  .layoutWeight(1 - c.rate / 100)
                  .height(7)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(3)
              }
              .width(110)
              .height(7)
              Text(c.rate + '%')
                .fontSize(9)
                .fontColor(c.color)
                .width(36)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (c: ClassJoinMeta) => c.name)

第二个卡片是课程报名进度排行,通过 ForEach 遍历 CLASS_JOIN 数组渲染。每行包含课程名称(layoutWeight(1) 弹性占位)、进度条(固定宽度 110)与百分比标注(固定宽度 36,右对齐)。进度条填充比例为 rate/100,色彩使用数据中预设的 color 字段。

与香调分布图的进度条相比,这里的进度条有一个关键差异——固定宽度 110 而非弹性宽度。这是因为报名进度是绝对数值(百分比),适合用固定宽度的进度条进行横向比较;而香调分布是相对比例,适合用弹性宽度铺满可用空间。这种"根据数据特性选择进度条宽度策略"的设计,体现了对数据可视化的深入理解。

百分比标注使用数据预设的色彩,与进度条填充色一致。这种"色彩-数字"关联编码,使得用户在扫视排行时,能通过色彩快速对应课程与数据。由于 CLASS_JOIN 按报名率从高到低排列,进度条从长到短排列,视觉上形成了"热度递减"的阅读节奏,排行榜的语义自然呈现。

第四十一段:沙龙内容页——热度排行

@Component
struct SalonContent {
  @ObjectLink data: PerfumeData;
  onDel: (n: string) => void = (n: string) => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🥂 沙龙热度榜')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('按报名人数')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(SALON_HOT, (s: SalonHotMeta) => {
            Row() {
              Text(s.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .layoutWeight(1)
              Row() {
                Row()
                  .layoutWeight(s.joined / 52)
                  .height(7)
                  .backgroundColor(s.color)
                  .borderRadius(3)
                Row()
                  .layoutWeight(1 - s.joined / 52)
                  .height(7)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(3)
              }
              .width(100)
              .height(7)
              Text(s.joined + '')
                .fontSize(9)
                .fontColor(s.color)
                .width(30)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (s: SalonHotMeta) => s.name)

SalonContent 是沙龙分页的内容组件,接收 @ObjectLink data 与 onDel 回调。onDel 带有名称参数,在点击删除时传递被删除的沙龙名称。第一个卡片是沙龙热度排行,通过 ForEach 遍历 SALON_HOT 数组渲染,结构与课程报名进度排行高度一致——名称(弹性占位)、进度条(固定宽度 100)、数值标注(固定宽度 30)。

进度条的填充比例为 joined/52,其中 52 是 SALON_HOT 中的最大值(年度新品发布 52 人)。这种"除以最大值"的归一化策略,确保了进度条最大填充恰好为 100%。如果未来数据变化导致最大值改变,需要同步修改除数。更健壮的实现可以使用 Math.max(…SALON_HOT.map(s => s.joined)) 动态计算最大值,但当前实现以简洁优先。

热度排行的右侧数值标注(s.joined + ‘’)将数字转为字符串,使用数据预设的色彩。与课程排行的百分比标注(rate + ‘%’)相比,沙龙排行展示的是绝对人数而非百分比,因为沙龙的"热度"更适合用参与人数直接比较,而非相对于某个总量的比例。这种"根据业务语义选择数值类型"的设计,使得数据展示更贴合业务理解。

第四十二段:沙龙内容页——排期列表项

        Column() {
          Row() {
            Text('📅 沙龙排期')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✕取消')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.salons, (s: SalonItem, i: number) => {
            Row() {
              Column() {
                Text(s.time.substring(5))
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(s.time.substring(0, 5))
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .width(42)
              .alignItems(HorizontalAlign.Center)
              .padding({ top: 6, bottom: 6 })
              .backgroundColor(COLORS.blush)
              .borderRadius(8)
              Column() {
                Row() {
                  Text(s.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: s.type, color: COLORS.primary })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(s.theme + ' · 报名 ' + s.joined + '/' + s.seats)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 10 })
              Text('✕')
                .fontSize(12)
                .fontColor(COLORS.textHint)
                .onClick(() => {
                  this.onDel(s.name);
                })

第二个卡片是沙龙排期列表。每个列表项的左侧是一个日历式的时间标签——Column 容器,宽 42,blush 粉色背景,圆角 8。内部包含两行:大号日期(s.time.substring(5),截取"MM-DD"的"DD"部分,12 号粗体)与小号月份(s.time.substring(0,5),保留"MM-DD",8 号灰色)。这种通过字符串截取实现日期分拆展示的技术,巧妙利用了 time 字段的"MM-DD"格式。

中间信息区包含沙龙名称与类型标签(PerfumeTag,使用 primary 金色),以及主题与报名进度信息。类型标签使用 primary 金色而非不同类型不同色,保持了标签层的视觉简洁。报名信息以"报名 35/40"的格式展示已报名/总座位,用户可以直观看到剩余名额。

右侧的 ✕ 关闭图标绑定 onDel 回调,传递当前沙龙的名称 s.name 作为参数。点击后,父组件将 delSalonName 设为该名称并打开删除确认弹窗。这种"列表项内嵌删除入口"的设计,需要配合确认弹窗以防误操作——应用通过 DeleteSalonModal 实现了二次确认。✕ 图标使用 textHint 灰色,低调但可发现,符合"危险操作轻量化入口"的设计原则。

第四十三段:定制内容页——订单列表

@Component
struct CustomContent {
  @ObjectLink data: PerfumeData;
  onDetail: (n: string) => void = (n: string) => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('💝 私人定制订单')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点卡片看进度')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.customs, (c: CustomItem, i: number) => {
            Row() {
              Text(c.emoji)
                .fontSize(20)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.gold)
                .borderRadius(21)
              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: c.stage, color: getStageColor(c.stage) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('客户 ' + c.client + ' · 周期 ' + c.days + ' 天')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 10 })
              Column() {
                Text('¥' + c.price)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.grape)
                Text(c.stage === '已完成' ? '✅' : '⏳')
                  .fontSize(11)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
            .onClick(() => {
              this.onDetail(c.name);
            })

CustomContent 是定制分页的内容组件,接收 @ObjectLink data 与 onDetail 回调。整个卡片只包含一个定制订单列表,没有第二个卡片。每个列表项的 Emoji 图标使用 gold 浅金色圆形背景(与香水列表项的 blush 粉色圆形背景形成差异),体现了定制业务与常规产品的视觉区分。

阶段标签使用 PerfumeTag 组件,色彩通过 getStageColor 函数动态返回——已完成为 success 绿色、调香中为 grape 紫色、香基调制为 primary 金色、打样中为 warning 橙色。这种"阶段语义化色彩"使得用户在扫视列表时,能通过标签色彩即时感知每笔订单的进展状态,绿色意味着已交付、紫色意味着核心工艺中、金色意味着原料调配中、橙色意味着等待打样确认。

右侧展示价格(grape 紫色粗体)与状态图标。状态图标采用条件逻辑:已完成的订单显示 ✅(绿色勾号),未完成的显示 ⏳(沙漏),通过 Emoji 直接传达"完成/进行中"的视觉语义。整个列表项绑定了 onClick 事件,点击时触发 onDetail 回调传递订单名称,打开详情弹窗。这种"整行可点击"的设计,使得用户点击列表项的任何位置都能查看详情,操作热区更大,体验更流畅。

第四十四段:新增香材弹窗——标题区

@Component
struct AddMaterialModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🌿')
            .fontSize(26)
          Column() {
            Text('新增香材')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('录入新进香材')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

AddMaterialModal 是新增香材弹窗组件,接收 onClose 回调。弹窗的整体结构是一个外层 Column(全屏遮罩+内容定位)包裹内层 Column(实际弹窗卡片)。内层卡片的顶部是标题行,包含 Emoji 图标(26 号大尺寸)、标题文本区与关闭按钮。

标题文本区是一个 Column,包含主标题"新增香材"(16 号粗体)与副标题"录入新进香材"(10 号灰色)。主副标题的字号差异与色彩差异形成了清晰的视觉层级。Column 的 alignItems(Start) 确保两行文本左对齐,layoutWeight(1) 使得标题区占据标题行的剩余空间,将关闭按钮推向行尾。

关闭按钮 ✕ 使用 textHint 灰色,16 号字号,绑定 onClose 回调。这是弹窗中最高频的操作入口之一——用户可能随时需要关闭弹窗。将关闭按钮放在标题行右侧,是移动端弹窗的标准设计规范,符合用户的操作习惯。✕ 的灰色低调但可发现,不会与标题争夺注意力,但始终可达。

第四十五段:新增香材弹窗——表单字段

        Row() {
          Text('香材名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('鸢尾根 · 佛罗伦萨')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 14 })

        Row() {
          Text('香调类别')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('花香调 · 粉感尾调')
            .fontSize(12)
            .fontColor(COLORS.danger)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('采购量')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('15kg · 单价 ¥920/kg')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('到货周期')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('12 天 · 冷藏存储')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

弹窗的表单区包含四个字段行:香材名称、香调类别、采购量与到货周期。每个字段行是一个 Row,左侧是标签文本(11 号 textSecondary,固定宽度 70),右侧是值文本(12 号,弹性占位)。行容器使用 bg 背景色、borderRadius(10) 与 padding(12),形成了浅灰背景的圆角字段块。

值得注意的是,这些字段值是静态文本而非输入框(TextInput)。这说明当前弹窗是一个"信息展示型"弹窗而非"数据录入型"弹窗——它展示了一条预设的新增香材信息(鸢尾根),用户只需确认入库。这种设计简化了交互流程,适合演示场景或"一键入库"的快捷操作。在真实业务中,可以将静态文本替换为 TextInput 组件实现真实的数据录入。

字段值的色彩有差异:香材名称用 textPrimary 深色,香调类别用 danger 玫粉色(呼应花香调的色彩语义),采购量用 textPrimary 深色,到货周期用 success 绿色(呼应冷藏存储的"安全"语义)。这种"值文本语义化着色"策略,使得字段值不仅是文字,更通过色彩传达了额外的业务信息。第一个字段的 margin 为 14(与标题行间距更大),后续字段为 8(字段间间距均匀),形成了"首字段与标题拉开距离、字段间均匀分布"的视觉节奏。

第四十六段:新增香材弹窗——提示与按钮

        Text('✅ 新香材入库前需完成气相色谱检测,确认无农残与掺假。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.bg)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('确认入库')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.grape)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })

表单区下方是一段提示文字:"✅ 新香材入库前需完成气相色谱检测,确认无农残与掺假。"这段文字使用 10 号 textHint 灰色,传达了入库流程中的质检要求。提示文字的存在使得弹窗不仅是操作入口,更承载了业务规范的教育功能。对于新入职的仓管人员,这种提示帮助其理解入库流程的关键环节。

底部按钮区是一个 Row,包含"取消"与"确认入库"两个按钮。取消按钮使用 bg 浅灰背景、textSecondary 灰色文字;确认按钮使用 grape 紫色背景、白色粗体文字。两个按钮都使用 layoutWeight(1) 等宽分布,padding({ top: 11, bottom: 11 }) 提供足够的点击热区,borderRadius(12) 与整体圆角语言一致。

确认按钮使用 grape 紫色背景,是应用中"主操作按钮"的统一色彩规范——在所有弹窗中,确认类按钮都使用 grape 色。这种色彩一致性使得用户在不同弹窗中都能快速识别主操作按钮。两个按钮的 onClick 都调用 onClose 回调,关闭弹窗。在真实业务中,确认按钮应该先执行入库逻辑(如写入数据)再关闭弹窗,当前实现以演示为主。

第四十七段:编辑香水弹窗——标题区

@Component
struct EditPerfumeModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🍾')
            .fontSize(26)
          Column() {
            Text('编辑香水')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('调整规格与定价')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

EditPerfumeModal 的结构与 AddMaterialModal 高度一致——相同的标题行布局、相同的 Emoji+标题+副标题+关闭按钮结构。差异仅在于内容:Emoji 是 🍾(香水瓶),标题是"编辑香水",副标题是"调整规格与定价"。这种结构的高度复用,是弹窗组件系统化设计的体现——所有弹窗遵循统一的标题区模板,差异仅在业务内容。

“调整规格与定价"的副标题精确传达了编辑弹窗的核心功能。与新增弹窗的"录入新进香材"相比,编辑弹窗的副标题更聚焦于具体操作——不是泛泛的"编辑”,而是明确的"调整规格与定价"。这种精确的副标题帮助用户在打开弹窗前就知道能做什么,降低了操作的不确定性。

弹窗组件的这种"模板化"设计,在工程上带来了显著的维护优势。如果需要调整所有弹窗的标题行样式(如修改 Emoji 字号或关闭按钮位置),只需在一处修改并复制到其他弹窗。更进一步的工程化做法是将标题区提取为一个独立的 @Builder 或 @Component,所有弹窗复用——但当前实现以"每个弹窗自包含"为策略,保留了各弹窗独立调整的灵活性。

第四十八段:编辑香水弹窗——表单字段

        Row() {
          Text('香水名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('晨雾玫瑰')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 14 })

        Row() {
          Text('当前售价')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('¥1280 → 建议 ¥1180')
            .fontSize(12)
            .fontColor(COLORS.danger)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('规格调整')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('新增 100ml 家庭装')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('月销量')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('96 瓶 · 环比 +18%')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

编辑弹窗的表单区包含四个字段:香水名称、当前售价、规格调整与月销量。与新增弹窗的静态展示不同,编辑弹窗的字段值体现了"变更建议"的语义——当前售价显示"¥1280 → 建议 ¥1180"(降价建议),月销量显示"96 瓶 · 环比 +18%"(增长趋势)。这些值文本传达了业务决策的上下文信息,帮助用户在编辑时做出判断。

值文本的色彩语义与新增弹窗一致:售价变更用 danger 玫粉色(价格信息),月销量增长用 success 绿色(积极信号),名称与规格用 textPrimary 深色(中性信息)。"¥1280 → 建议 ¥1180"中的箭头符号 → 直观传达了"从…调整为…"的变更方向,用户无需计算即可理解建议降幅。环比 +18% 的增长数据,为调价决策提供了市场反馈依据——销量上升时降价可能不是最优策略,这一信息帮助用户审慎决策。

四个字段行的布局与新增弹窗完全一致——相同的标签宽度(70)、相同的 padding(12)、相同的 borderRadius(10)、相同的 margin 节奏(首字段 14、后续 8)。这种跨弹窗的布局一致性,使得用户在不同弹窗中的阅读体验统一,无需重新适应字段排列逻辑。

第四十九段:编辑香水弹窗——提示与按钮

        Text('✅ 调价后将同步至官网与门店终端,请确认包装批次一致。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.bg)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('保存修改')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.primary)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })

提示文字"✅ 调价后将同步至官网与门店终端,请确认包装批次一致"传达了调价操作的全局影响——价格变更不仅影响当前系统,还会同步到官网与门店终端。这种提示提醒用户调价是"牵一发而动全身"的操作,需要谨慎确认。同时"包装批次一致"的提醒,关联了调价与包装管理的业务关联,体现了对业务流程的深入理解。

底部按钮区与新增弹窗结构一致——取消与确认两个等宽按钮。差异在于确认按钮的背景色:新增弹窗使用 grape 紫色,编辑弹窗使用 primary 香槟金色。这种色彩差异是有意为之——新增操作与编辑操作在业务语义上不同,使用不同色彩的主按钮帮助用户区分当前操作类型。primary 金色对应"修改"语义,grape 紫色对应"新增"语义,形成了操作类型的色彩编码。

“保存修改"的按钮文字比"确认入库"更贴近编辑场景的语义。每个弹窗的确认按钮文字都与其业务场景匹配——新增用"入库”、编辑用"保存"、删除用"确认取消"、详情用"关闭"。这种"按钮文字场景化"的设计,使得操作语义更加精确,用户在点击前能明确知道操作的后果。

第五十段:取消沙龙弹窗

@Component
struct DeleteSalonModal {
  title: string = '';
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🥂')
            .fontSize(26)
          Column() {
            Text('取消沙龙')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('确认移除所选场次')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Text('沙龙名称:' + this.title)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.blush)
          .borderRadius(10)
          .margin({ top: 14 })

        Text('⚠️ 取消后已报名嘉宾将收到全额退款,请提前发布延期公告。')
          .fontSize(10)
          .fontColor(COLORS.warning)
          .width('100%')
          .margin({ top: 12 })

        Row() {
          Text('保留场次')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.bg)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('确认取消')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.danger)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })

DeleteSalonModal 是取消沙龙的确认弹窗,接收 title(沙龙名称)与 onClose 两个参数。标题区的结构与前两个弹窗一致,但内容区有显著差异——它展示的是被取消的沙龙名称(this.title)与取消后果的警告,而非表单字段。

沙龙名称展示为一个 blush 粉色背景的字段块,文本使用 danger 玫粉色粗体。这种"粉色背景+红色文字"的组合,形成了一个醒目的信息块,确保用户在操作前能明确看到自己要取消的是哪场沙龙。title 参数由父组件在点击列表项的 ✕ 按钮时设置(this.delSalonName = n),通过参数传递实现了"操作目标"的跨组件传递。

警告文字"⚠️ 取消后已报名嘉宾将收到全额退款,请提前发布延期公告"使用 warning 琥珀橙色,传达了取消操作的连锁后果。这段文字不仅提醒用户退款义务,还建议了"提前发布延期公告"的操作规范,将业务流程的最佳实践嵌入到了确认弹窗中。这种"在危险操作前提供业务指导"的设计,降低了误操作导致的业务风险。

底部按钮的文案与色彩与前两个弹窗不同——“保留场次"与"确认取消”。"保留场次"使用 bg 灰色(对应取消操作),"确认取消"使用 danger 玫粉色(对应危险操作)。danger 色的确认按钮是应用中"危险确认"的统一色彩规范——在所有涉及删除或取消的弹窗中,确认按钮使用 danger 色以警示风险。弹窗卡片的边框也使用了 danger + ‘40’(半透明红色),从视觉上整体传达"危险操作"的语义。

第五十一段:定制详情弹窗——标题区

@Component
struct DetailCustomModal {
  name: string = '';
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('💝')
            .fontSize(26)
          Column() {
            Text('定制进度')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(this.name)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

DetailCustomModal 是定制订单详情弹窗,接收 name(订单名称)与 onClose 参数。标题区的结构与其他弹窗一致,但副标题位置显示的是 this.name(订单名称),而非固定的描述文字。这种"副标题动态化"的设计,使得弹窗标题区直接呈现当前查看的订单名称,用户无需在内容区寻找名称确认。

标题"定制进度"传达了弹窗的核心功能——展示定制订单的进展详情。与取消沙龙弹窗的"确认移除"和编辑弹窗的"调整规格与定价"相比,详情弹窗的标题更偏向"信息展示"而非"操作执行"。这种功能定位的差异,也影响了弹窗底部按钮的设计——详情弹窗只有一个"关闭"按钮,而非"取消+确认"的双按钮组合。

name 参数的传递链路与 DeleteSalonModal 的 title 参数一致——由父组件在点击列表项时设置(this.detailCustom = n),通过参数传递给弹窗。这种"参数传递实现操作上下文"的模式,在所有带上下文的弹窗中统一使用,确保了弹窗能够获取到用户操作的目标对象。

第五十二段:定制详情弹窗——字段区

        Row() {
          Text('香调方向')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('花香前调 · 檀木尾调')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 14 })

        Row() {
          Text('当前阶段')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('打样中 · 剩余 8 天')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('包装设计')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('鎏金瓶身 · 烫印姓名')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('专属服务')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('调香师 1v1 · 终身复刻')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

详情弹窗的字段区包含四个字段:香调方向、当前阶段、包装设计与专属服务。这些字段涵盖了定制订单从香调设计到包装再到服务的全流程信息。字段值的色彩语义丰富——香调方向用 textPrimary 深色(中性信息),当前阶段用 warning 橙色(进行中状态),包装设计用 textPrimary 深色(中性信息),专属服务用 success 绿色(附加价值信息)。

"打样中 · 剩余 8 天"的字段值结合了阶段状态与时间预估,为用户提供了完整的进度信息。warning 橙色传达了"进行中、需等待"的语义。这种"阶段+时间"的复合信息展示,比单纯展示阶段名称更有价值——用户不仅知道当前在哪个阶段,还知道预计何时完成。

"调香师 1v1 · 终身复刻"的专属服务字段用 success 绿色展示,传达了这是"已包含的增值服务"的积极信号。这种将服务亮点用绿色高亮的设计,在详情弹窗中起到了"价值强化"的作用——用户在查看进度时,同时被提醒了自己享受的专属权益,增强了品牌忠诚度。所有字段行的布局与之前弹窗完全一致,保持了跨弹窗的结构一致性。

第五十三段:定制详情弹窗——提示与按钮

        Text('💝 定制香水将在交付前安排三版打样试香,确认后再定稿灌装。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('关闭')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.grape)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

详情弹窗的提示文字"💝 定制香水将在交付前安排三版打样试香,确认后再定稿灌装"传达了定制流程的质检规范。三版打样试香的承诺,向用户展示了工作室对品质的把控标准。这段提示使用 textHint 灰色,作为弹窗的"脚注"信息,低调但提供了重要的流程说明。

底部按钮区只有一个"关闭"按钮,使用 grape 紫色背景与白色粗体文字。与之前弹窗的双按钮布局不同,详情弹窗作为纯展示型弹窗,不需要"取消+确认"的双按钮——用户查看完信息后只需关闭。按钮使用 layoutWeight(1) 占满整行宽度,提供了更大的点击热区。

弹窗卡片容器使用 cardBg 白色背景、borderRadius(18) 大圆角、1 像素 gold 金色边框。外层 Column 使用 justifyContent(FlexAlign.End) 将弹窗卡片推到底部,constraintSize({ maxHeight: ‘78%’ }) 限制弹窗最大高度为屏幕的 78%,确保弹窗不会覆盖整个屏幕,保留了顶部的空间感,形成"底部弹出"的视觉感受。这种底部弹窗模式是移动端弹窗的主流设计——从底部滑入,不遮挡顶栏,用户可以随时看到当前上下文。所有四个弹窗都使用相同的 maxHeight(78%) 与 FlexAlign.End,确保了弹窗弹出方式的一致性。

技术对比表格

技术要点 实现方式 优势 适用场景
色彩体系管理 PerfumePalette 接口 + COLORS 常量集中定义 单一数据源,主题调整一处修改全局生效 中大型应用需统一品牌色彩
色彩透明度变体 十六进制 Alpha 通道后缀拼接(如 color+‘1F’) 无需额外定义浅色变量,运行时动态生成 需要同色不同透明度的标签、背景、边框
状态深层追踪 @Observed 标记数据类 + @State 实例化 类属性变更自动触发关联组件重新渲染 需要追踪对象属性级变更的场景
子组件数据绑定 @ObjectLink 接收 @Observed 类实例 父子组件数据双向同步,修改自动回流 数据需要跨多层组件共享与修改
单向数据传递 @Prop 接收只读参数 子组件无法修改,数据流向可控 纯展示型组件如标签、徽章
跨组件事件通信 回调函数作为组件参数传入 子组件向父组件冒泡事件,父组件统一调度 子组件触发父组件状态变更
弹窗管理 布尔状态变量 + 条件渲染 按需挂载,无弹窗时零内存占用 弹窗数量有限的页面级应用
分页调度 枚举索引 + if-else 条件渲染 切换即时,无需页面跳转 单页面多模块切换
分页栏渲染 ForEach 遍历元数据数组 数据驱动 UI,新增分页只需加数组项 分页数量可能变化的场景
柱状图实现 ForEach + Column + 高度按比例 纯代码实现,无第三方图表库依赖 简单数据可视化,柱子数量有限
进度条实现 双 Row 拼接 + layoutWeight 比例分配 纯布局代码实现,弹性宽度自适应 百分比与比例数据展示
通用标签复用 @Component + @Prop 参数化 一次编写多处复用,统一维护 多处需要同类型标签的业务
声明式动画 @State 布尔翻转 + animation 装饰器 自动插值过渡,无需手动控制动画帧 微交互与状态切换动画
条件着色 三元运算符判断阈值返回色彩 数据驱动视觉重点,自动高亮关键信息 需要突出特定数据的图表与列表
字符串截取展示 substring 分拆复合字段 避免冗余存储,运行时动态分拆 日期、地址等复合格式字段
阶段语义编码 字符串映射色彩的工具函数 阶段状态通过色彩即时感知 多状态业务流程的列表展示
弹窗底部弹出 justifyContent(End) + maxHeight 限制 保留顶栏可见性,上下文不丢失 移动端操作确认与详情展示
列表项操作入口 内嵌小图标 + 回调冒泡 操作热区精确到具体列表项 针对单个列表项的编辑、删除
Emoji 图标系统 Unicode Emoji 作为图标替代图标字体 零资源依赖,跨平台一致,渲染快速 消费级应用与情感化交互场景
色彩指示条 列表项左侧 Column 色块 扫视列表时快速识别分类 长列表的分类视觉编码

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface PerfumePalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  grape: string;
  grapeLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  blush: string;
  gold: string;
}

const COLORS: PerfumePalette = {
  primary: '#C9A227',
  primaryLight: '#E3C96B',
  primaryDark: '#97761A',
  accent: '#9B7EDE',
  accentLight: '#EDE6FB',
  grape: '#9B7EDE',
  grapeLight: '#EDE6FB',
  bg: '#FBF7F2',
  cardBg: '#FFFFFF',
  textPrimary: '#3E3355',
  textSecondary: '#7C718C',
  textHint: '#B5ACBF',
  line: '#EFE7E0',
  success: '#7FBF7F',
  warning: '#E0A458',
  danger: '#D96C8C',
  white: '#FFFFFF',
  blush: '#F7DDE8',
  gold: '#E8D9A8'
};

enum PerfumeTab {
  MATERIAL = 0,
  PERFUME = 1,
  CLASS = 2,
  SALON = 3,
  CUSTOM = 4
}

interface TabMeta {
  key: string;
  icon: string;
  label: string;
  color: string;
}

const TAB_LIST: TabMeta[] = [
  { key: 'material', icon: '🌿', label: '香材', color: '#7FBF7F' },
  { key: 'perfume', icon: '🍾', label: '香水', color: '#C9A227' },
  { key: 'class', icon: '🧪', label: '调香课', color: '#9B7EDE' },
  { key: 'salon', icon: '🥂', label: '沙龙', color: '#D96C8C' },
  { key: 'custom', icon: '💝', label: '定制', color: '#E0A458' }
];

interface MaterialItem {
  name: string;
  origin: string;
  note: string;
  stock: number;
  usage: number;
  price: number;
  emoji: string;
}

interface PerfumeItem {
  name: string;
  series: string;
  price: number;
  ml: number;
  sales: number;
  rating: number;
  emoji: string;
}

interface ClassItem {
  name: string;
  coach: string;
  price: number;
  seats: number;
  joined: number;
  level: string;
  emoji: string;
}

interface SalonItem {
  name: string;
  theme: string;
  time: string;
  seats: number;
  joined: number;
  type: string;
  emoji: string;
}

interface CustomItem {
  name: string;
  client: string;
  price: number;
  days: number;
  stage: string;
  emoji: string;
}

interface WeekMeta {
  day: string;
  value: number;
}

interface NoteMeta {
  name: string;
  count: number;
  color: string;
}

interface ClassJoinMeta {
  name: string;
  rate: number;
  color: string;
}

interface SalonHotMeta {
  name: string;
  joined: number;
  color: string;
}

const WEEK_SALES: WeekMeta[] = [
  { day: '周一', value: 36 },
  { day: '周二', value: 42 },
  { day: '周三', value: 30 },
  { day: '周四', value: 48 },
  { day: '周五', value: 65 },
  { day: '周六', value: 88 },
  { day: '周日', value: 74 }
];

const NOTE_SHARE: NoteMeta[] = [
  { name: '花香调', count: 2, color: '#D96C8C' },
  { name: '木质调', count: 2, color: '#9B7EDE' },
  { name: '柑橘调', count: 2, color: '#E0A458' },
  { name: '海洋调', count: 2, color: '#7FBF7F' },
  { name: '美食调', count: 2, color: '#C9A227' },
  { name: '奢华调', count: 2, color: '#E8D9A8' }
];

const CLASS_JOIN: ClassJoinMeta[] = [
  { name: '香水历史入门', rate: 92, color: '#9B7EDE' },
  { name: '周末体验班', rate: 90, color: '#C9A227' },
  { name: '儿童香水课', rate: 88, color: '#7FBF7F' },
  { name: '婚礼定制课', rate: 75, color: '#D96C8C' },
  { name: '品牌调香营', rate: 67, color: '#E0A458' }
];

const SALON_HOT: SalonHotMeta[] = [
  { name: '年度新品发布', joined: 52, color: '#C9A227' },
  { name: '七夕香氛夜', joined: 46, color: '#D96C8C' },
  { name: '春日花园品鉴', joined: 35, color: '#7FBF7F' },
  { name: '中秋桂花专场', joined: 33, color: '#E0A458' },
  { name: '古法香囊手作', joined: 28, color: '#9B7EDE' },
  { name: '香水盲测挑战', joined: 26, color: '#E8D9A8' }
];

@Observed
export class PerfumeData {
  materials: MaterialItem[] = [
    { name: '大马士革玫瑰', origin: '法国', note: '花香调', stock: 12, usage: 88, price: 860, emoji: '🥀' },
    { name: '佛手柑', origin: '意大利', note: '柑橘调', stock: 20, usage: 92, price: 320, emoji: '🍋' },
    { name: '茉莉原精', origin: '印度', note: '花香调', stock: 8, usage: 95, price: 1280, emoji: '🌼' },
    { name: '雪松木', origin: '加拿大', note: '木质调', stock: 30, usage: 78, price: 260, emoji: '🌲' },
    { name: '广藿香', origin: '印尼', note: '木质调', stock: 15, usage: 72, price: 480, emoji: '🍂' },
    { name: '香草荚', origin: '马达加斯加', note: '美食调', stock: 10, usage: 85, price: 980, emoji: '🍦' },
    { name: '白麝香', origin: '瑞士', note: '麝香调', stock: 6, usage: 90, price: 1560, emoji: '🕊️' },
    { name: '橙花', origin: '突尼斯', note: '花香调', stock: 9, usage: 82, price: 720, emoji: '🍊' },
    { name: '檀香木', origin: '印度', note: '木质调', stock: 7, usage: 76, price: 2180, emoji: '🪵' },
    { name: '黑胡椒', origin: '印度', note: '辛香调', stock: 14, usage: 68, price: 240, emoji: '🌶️' },
    { name: '依兰依兰', origin: '菲律宾', note: '花香调', stock: 11, usage: 86, price: 540, emoji: '🌺' },
    { name: '海盐', origin: '法国', note: '海洋调', stock: 18, usage: 80, price: 160, emoji: '🧂' }
  ];
  perfumes: PerfumeItem[] = [
    { name: '晨雾玫瑰', series: '花园系列', price: 1280, ml: 50, sales: 96, rating: 4.9, emoji: '🌹' },
    { name: '海岸假日', series: '度假系列', price: 1080, ml: 50, sales: 88, rating: 4.8, emoji: '🏖️' },
    { name: '雪松之夜', series: '木质系列', price: 1580, ml: 50, sales: 82, rating: 4.9, emoji: '🌲' },
    { name: '蜜桃乌龙', series: '果香系列', price: 880, ml: 30, sales: 91, rating: 4.8, emoji: '🍑' },
    { name: '广藿之吻', series: '皮革系列', price: 1880, ml: 50, sales: 76, rating: 4.8, emoji: '🖤' },
    { name: '白茶清欢', series: '茶香系列', price: 980, ml: 50, sales: 85, rating: 4.9, emoji: '🍵' },
    { name: '星夜麝香', series: '高端线', price: 2380, ml: 50, sales: 68, rating: 5.0, emoji: '✨' },
    { name: '橙花小径', series: '花香系列', price: 780, ml: 30, sales: 89, rating: 4.7, emoji: '🍊' },
    { name: '海洋之息', series: '海洋系列', price: 1180, ml: 50, sales: 79, rating: 4.8, emoji: '🌊' },
    { name: '香草梦境', series: '美食系列', price: 880, ml: 50, sales: 87, rating: 4.8, emoji: '🍦' },
    { name: '檀道', series: '高端线', price: 3280, ml: 75, sales: 62, rating: 5.0, emoji: '🪵' },
    { name: '晨露茉莉', series: '花园系列', price: 1080, ml: 50, sales: 84, rating: 4.9, emoji: '🌼' }
  ];
  classes: ClassItem[] = [
    { name: '香水历史入门', coach: 'Lily', price: 399, seats: 25, joined: 23, level: '初级', emoji: '📜' },
    { name: '调香基础理论', coach: 'Leo', price: 599, seats: 20, joined: 17, level: '初级', emoji: '🧪' },
    { name: '花香调进阶', coach: 'Lily', price: 899, seats: 15, joined: 12, level: '中级', emoji: '🌸' },
    { name: '木质调特训', coach: 'Leo', price: 999, seats: 12, joined: 9, level: '中级', emoji: '🌲' },
    { name: '柑橘调灵感课', coach: 'Mia', price: 699, seats: 18, joined: 15, level: '中级', emoji: '🍋' },
    { name: '商业香水拆解', coach: 'Leo', price: 1299, seats: 10, joined: 8, level: '高级', emoji: '💼' },
    { name: '沙龙香体验', coach: 'Mia', price: 1099, seats: 14, joined: 11, level: '高级', emoji: '🥂' },
    { name: '婚礼定制课', coach: 'Lily', price: 1599, seats: 8, joined: 6, level: '高级', emoji: '💍' },
    { name: '儿童香水课', coach: 'Mia', price: 499, seats: 16, joined: 14, level: '初级', emoji: '🧒' },
    { name: '香评写作课', coach: 'Lily', price: 599, seats: 20, joined: 16, level: '中级', emoji: '✍️' },
    { name: '品牌调香营', coach: 'Leo', price: 2999, seats: 6, joined: 4, level: '大师', emoji: '👑' },
    { name: '周末体验班', coach: 'Mia', price: 349, seats: 30, joined: 27, level: '初级', emoji: '🎉' }
  ];
  salons: SalonItem[] = [
    { name: '春日花园品鉴', theme: '品鉴会', time: '07-18', seats: 40, joined: 35, type: '花香', emoji: '🏵️' },
    { name: '东方香调之旅', theme: '讲座', time: '07-26', seats: 30, joined: 24, type: '木质', emoji: '🀄' },
    { name: '调香师对谈', theme: '对谈', time: '08-03', seats: 25, joined: 21, type: '综合', emoji: '🎙️' },
    { name: '七夕香氛夜', theme: '主题夜', time: '08-10', seats: 50, joined: 46, type: '花香', emoji: '💘' },
    { name: '皮革香水私享', theme: '私享会', time: '08-17', seats: 20, joined: 15, type: '皮革', emoji: '🖤' },
    { name: '古法香囊手作', theme: '手作', time: '08-24', seats: 30, joined: 28, type: '民俗', emoji: '🧵' },
    { name: '海洋香调溯源', theme: '讲座', time: '09-01', seats: 25, joined: 19, type: '海洋', emoji: '🌊' },
    { name: '香水摄影课', theme: '课程', time: '09-08', seats: 20, joined: 14, type: '摄影', emoji: '📷' },
    { name: '中秋桂花专场', theme: '品鉴会', time: '09-15', seats: 40, joined: 33, type: '花香', emoji: '🌕' },
    { name: '红酒与香水', theme: '跨界', time: '09-22', seats: 25, joined: 20, type: '跨界', emoji: '🍷' },
    { name: '香水盲测挑战', theme: '互动', time: '09-29', seats: 30, joined: 26, type: '互动', emoji: '🎯' },
    { name: '年度新品发布', theme: '发布会', time: '10-10', seats: 60, joined: 52, type: '新品', emoji: '🎉' }
  ];
  customs: CustomItem[] = [
    { name: '定制「初见」', client: '林小姐', price: 3880, days: 21, stage: '已完成', emoji: '💐' },
    { name: '定制「星语」', client: '周先生', price: 4580, days: 30, stage: '调香中', emoji: '✨' },
    { name: '定制「山岚」', client: '陈女士', price: 3280, days: 18, stage: '打样中', emoji: '🏔️' },
    { name: '定制「绯闻」', client: '王小姐', price: 2680, days: 14, stage: '已完成', emoji: '🌹' },
    { name: '定制「晨钟」', client: '刘先生', price: 5880, days: 45, stage: '香基调制', emoji: '🔔' },
    { name: '定制「晚风」', client: '赵女士', price: 3980, days: 25, stage: '已完成', emoji: '🌙' },
    { name: '定制「渔火」', client: '孙先生', price: 3480, days: 20, stage: '打样中', emoji: '🔥' },
    { name: '定制「白茶」', client: '钱小姐', price: 2980, days: 16, stage: '调香中', emoji: '🍵' },
    { name: '定制「星河」', client: '吴先生', price: 6880, days: 60, stage: '香基调制', emoji: '🌌' },
    { name: '定制「晨曦」', client: '郑女士', price: 3180, days: 19, stage: '已完成', emoji: '🌅' },
    { name: '定制「蜜语」', client: '冯小姐', price: 2780, days: 15, stage: '调香中', emoji: '🍯' },
    { name: '定制「远山」', client: '韩先生', price: 4280, days: 28, stage: '打样中', emoji: '⛰️' }
  ];
}

function getStageColor(stage: string): string {
  return stage === '已完成' ? COLORS.success : (stage === '调香中' ? COLORS.grape : (stage === '香基调制' ? COLORS.primary : COLORS.warning));
}

function getNoteColor(note: string): string {
  return note === '花香调' ? COLORS.danger : (note === '木质调' ? COLORS.grape : (note === '柑橘调' ? COLORS.warning : (note === '海洋调' ? COLORS.success : COLORS.primary)));
}

@Entry
@Component
struct PerfumeApp {
  @State curTab: number = 0;
  @State data: PerfumeData = new PerfumeData();
  @State showAddMaterial: boolean = false;
  @State showEditPerfume: boolean = false;
  @State showDeleteSalon: boolean = false;
  @State showDetail: boolean = false;
  @State delSalonName: string = '';
  @State detailCustom: string = '';
  @State petalFall: boolean = false;
  @State tubeShake: boolean = false;

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
    }
    .width(1)
    .height(1)
  }

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('🧪 香水调香室')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('Perfume Atelier · 匠心调香')
                .fontSize(10)
                .fontColor('#FFFFFFCC')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Row() {
              Text('🌸')
                .fontSize(20)
                .onClick(() => {
                  this.petalFall = !this.petalFall;
                })
                .translate({ y: this.petalFall ? 10 : 0 })
                .rotate({ angle: this.petalFall ? 30 : 0 })
                .animation({ duration: 600, curve: Curve.EaseOut })
              Text('🧪')
                .fontSize(16)
                .margin({ left: 10 })
                .onClick(() => {
                  this.tubeShake = !this.tubeShake;
                })
                .rotate({ angle: this.tubeShake ? -15 : 0 })
                .animation({ duration: 400, curve: Curve.EaseOut })
              Text('🥂')
                .fontSize(16)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor('#FFFFFF1F')
            .borderRadius(18)
            .border({ width: 1, color: '#FFFFFF40' })
          }
          .width('100%')
          Row() {
            Text('香槟金')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
            Text('雾紫')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
            Text('玫瑰粉')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.grape)

        if (this.curTab === PerfumeTab.MATERIAL) {
          MaterialContent({ data: this.data, onAdd: () => {
            this.showAddMaterial = true;
          } })
        } else if (this.curTab === PerfumeTab.PERFUME) {
          PerfumeContent({ data: this.data, onEdit: () => {
            this.showEditPerfume = true;
          } })
        } else if (this.curTab === PerfumeTab.CLASS) {
          ClassContent({ data: this.data })
        } else if (this.curTab === PerfumeTab.SALON) {
          SalonContent({ data: this.data, onDel: (n: string) => {
            this.delSalonName = n;
            this.showDeleteSalon = true;
          } })
        } else {
          CustomContent({ data: this.data, onDetail: (n: string) => {
            this.detailCustom = n;
            this.showDetail = true;
          } })
        }
      }
      .width('100%')
      .height('100%')

      Row() {
        ForEach(TAB_LIST, (t: TabMeta) => {
          Column() {
            Text(t.icon)
              .fontSize(19)
            Text(t.label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_LIST.indexOf(t) ? t.color : COLORS.textHint)
              .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.gold : COLORS.cardBg)
          .borderRadius(16)
          .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '55' } : { width: 1, color: COLORS.line })
          .onClick(() => {
            this.curTab = TAB_LIST.indexOf(t);
          })
        }, (t: TabMeta) => t.key)
      }
      .width('100%')
      .height(60)
      .padding({ left: 8, right: 8 })
      .backgroundColor(COLORS.cardBg)
      .border({ width: 1, color: COLORS.line })

      if (this.showAddMaterial) {
        AddMaterialModal({
          onClose: () => {
            this.showAddMaterial = false;
          }
        })
      }
      if (this.showEditPerfume) {
        EditPerfumeModal({
          onClose: () => {
            this.showEditPerfume = false;
          }
        })
      }
      if (this.showDeleteSalon) {
        DeleteSalonModal({
          title: this.delSalonName, onClose: () => {
            this.showDeleteSalon = false;
          }
        })
      }
      if (this.showDetail) {
        DetailCustomModal({
          name: this.detailCustom, onClose: () => {
            this.showDetail = false;
          }
        })
      }
    }
  }
}

@Component
struct PerfumeTag {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .backgroundColor(this.color + '1F')
      .borderRadius(6)
      .border({ width: 1, color: this.color + '40' })
  }
}

@Component
struct MaterialContent {
  @ObjectLink data: PerfumeData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 本周销量(瓶)')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六峰值')
              .fontSize(9)
              .fontColor(COLORS.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_SALES, (w: WeekMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 70 ? COLORS.grape : COLORS.textSecondary)
                Column()
                  .width(20)
                  .height(w.value * 1.05)
                  .backgroundColor(w.value >= 70 ? COLORS.grape : COLORS.primary)
                  .borderRadius(4)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekMeta) => w.day)
          }
          .width('100%')
          .height(140)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        Column() {
          Row() {
            Text('🌸 香调分布')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('全系列12款')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(NOTE_SHARE, (n: NoteMeta) => {
            Row() {
              Text(n.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(60)
              Row() {
                Row()
                  .layoutWeight(n.count / 2)
                  .height(8)
                  .backgroundColor(n.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1 - n.count / 2)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .height(8)
              Text(n.count + '款')
                .fontSize(9)
                .fontColor(n.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (n: NoteMeta) => n.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('🌿 香材库存')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕采购')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.materials, (m: MaterialItem, i: number) => {
            Row() {
              Column()
                .width(6)
                .height(38)
                .backgroundColor(getNoteColor(m.note))
                .borderRadius(3)
              Text(m.emoji)
                .fontSize(18)
                .width(36)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(m.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: m.note, color: getNoteColor(m.note) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('产地 ' + m.origin + ' · 库存 ' + m.stock + 'kg')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Row() {
                  Row()
                    .layoutWeight(m.usage / 100)
                    .height(5)
                    .backgroundColor(m.usage >= 90 ? COLORS.danger : COLORS.primary)
                    .borderRadius(2)
                  Row()
                    .layoutWeight(1 - m.usage / 100)
                    .height(5)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(2)
                }
                .width('100%')
                .height(5)
                .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text('¥' + m.price)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.grape)
                Text('用量' + m.usage)
                  .fontSize(9)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (m: MaterialItem, i: number) => m.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.blush + '66')
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct PerfumeContent {
  @ObjectLink data: PerfumeData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🍾 香水系列')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✎调价')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.perfumes, (p: PerfumeItem, i: number) => {
            Row() {
              Text(p.emoji)
                .fontSize(20)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.blush)
                .borderRadius(21)
              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: p.series, color: COLORS.grape })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('规格 ' + p.ml + 'ml · 月销 ' + p.sales + ' 瓶')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 10 })
              Column() {
                Text('¥' + p.price)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.danger)
                Text('⭐' + p.rating)
                  .fontSize(9)
                  .fontColor(COLORS.primary)
                  .margin({ top: 3 })
                Text('✎')
                  .fontSize(12)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 3 })
                  .onClick(() => {
                    this.onEdit();
                  })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (p: PerfumeItem, i: number) => p.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        Column() {
          Row() {
            Text('💡 调香小课堂')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('本周更新')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 8 })
          Row() {
            Text('🌹')
              .fontSize(16)
            Text('前调留香约 15 分钟,柑橘类香材优先挥发')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ left: 8 })
              .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .margin({ bottom: 8 })
          Row() {
            Text('🌲')
              .fontSize(16)
            Text('木质后调可持香 6-8 小时,适合秋冬使用')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ left: 8 })
              .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .margin({ bottom: 8 })
          Row() {
            Text('🧪')
              .fontSize(16)
            Text('酒精浓度 15%-20% 的淡香水留香约 3-4 小时')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ left: 8 })
              .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.blush + '66')
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct ClassContent {
  @ObjectLink data: PerfumeData;

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🧪 调香课程')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('预约制小班')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.classes, (c: ClassItem, i: number) => {
            Row() {
              Text(c.emoji)
                .fontSize(20)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.grapeLight)
                .borderRadius(12)
              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: c.level, color: COLORS.grape })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('讲师 ' + c.coach + ' · 报名 ' + c.joined + '/' + c.seats)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Row() {
                  Row()
                    .layoutWeight(c.joined / c.seats)
                    .height(5)
                    .backgroundColor(c.joined === c.seats ? COLORS.danger : COLORS.grape)
                    .borderRadius(2)
                  Row()
                    .layoutWeight(1 - c.joined / c.seats)
                    .height(5)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(2)
                }
                .width('100%')
                .height(5)
                .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 10 })
              Column() {
                Text('¥' + c.price)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.danger)
                Text(c.joined === c.seats ? '已满员' : '有名额')
                  .fontSize(9)
                  .fontColor(c.joined === c.seats ? COLORS.danger : COLORS.success)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (c: ClassItem, i: number) => c.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        Column() {
          Row() {
            Text('🎟️ 报名进度')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('热门课程')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(CLASS_JOIN, (c: ClassJoinMeta) => {
            Row() {
              Text(c.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .layoutWeight(1)
              Row() {
                Row()
                  .layoutWeight(c.rate / 100)
                  .height(7)
                  .backgroundColor(c.color)
                  .borderRadius(3)
                Row()
                  .layoutWeight(1 - c.rate / 100)
                  .height(7)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(3)
              }
              .width(110)
              .height(7)
              Text(c.rate + '%')
                .fontSize(9)
                .fontColor(c.color)
                .width(36)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (c: ClassJoinMeta) => c.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.blush + '66')
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct SalonContent {
  @ObjectLink data: PerfumeData;
  onDel: (n: string) => void = (n: string) => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🥂 沙龙热度榜')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('按报名人数')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(SALON_HOT, (s: SalonHotMeta) => {
            Row() {
              Text(s.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .layoutWeight(1)
              Row() {
                Row()
                  .layoutWeight(s.joined / 52)
                  .height(7)
                  .backgroundColor(s.color)
                  .borderRadius(3)
                Row()
                  .layoutWeight(1 - s.joined / 52)
                  .height(7)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(3)
              }
              .width(100)
              .height(7)
              Text(s.joined + '')
                .fontSize(9)
                .fontColor(s.color)
                .width(30)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (s: SalonHotMeta) => s.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })

        Column() {
          Row() {
            Text('📅 沙龙排期')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✕取消')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.salons, (s: SalonItem, i: number) => {
            Row() {
              Column() {
                Text(s.time.substring(5))
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(s.time.substring(0, 5))
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .width(42)
              .alignItems(HorizontalAlign.Center)
              .padding({ top: 6, bottom: 6 })
              .backgroundColor(COLORS.blush)
              .borderRadius(8)
              Column() {
                Row() {
                  Text(s.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: s.type, color: COLORS.primary })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(s.theme + ' · 报名 ' + s.joined + '/' + s.seats)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 10 })
              Text('✕')
                .fontSize(12)
                .fontColor(COLORS.textHint)
                .onClick(() => {
                  this.onDel(s.name);
                })
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (s: SalonItem, i: number) => s.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.blush + '66')
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct CustomContent {
  @ObjectLink data: PerfumeData;
  onDetail: (n: string) => void = (n: string) => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('💝 私人定制订单')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点卡片看进度')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.customs, (c: CustomItem, i: number) => {
            Row() {
              Text(c.emoji)
                .fontSize(20)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.gold)
                .borderRadius(21)
              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  PerfumeTag({ text: c.stage, color: getStageColor(c.stage) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('客户 ' + c.client + ' · 周期 ' + c.days + ' 天')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 10 })
              Column() {
                Text('¥' + c.price)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.grape)
                Text(c.stage === '已完成' ? '✅' : '⏳')
                  .fontSize(11)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
            .onClick(() => {
              this.onDetail(c.name);
            })
          }, (c: CustomItem, i: number) => c.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct AddMaterialModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🌿')
            .fontSize(26)
          Column() {
            Text('新增香材')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('录入新进香材')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('香材名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('鸢尾根 · 佛罗伦萨')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 14 })

        Row() {
          Text('香调类别')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('花香调 · 粉感尾调')
            .fontSize(12)
            .fontColor(COLORS.danger)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('采购量')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('15kg · 单价 ¥920/kg')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('到货周期')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('12 天 · 冷藏存储')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Text('✅ 新香材入库前需完成气相色谱检测,确认无农残与掺假。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.bg)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('确认入库')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.grape)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct EditPerfumeModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🍾')
            .fontSize(26)
          Column() {
            Text('编辑香水')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('调整规格与定价')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('香水名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('晨雾玫瑰')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 14 })

        Row() {
          Text('当前售价')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('¥1280 → 建议 ¥1180')
            .fontSize(12)
            .fontColor(COLORS.danger)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('规格调整')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('新增 100ml 家庭装')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('月销量')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('96 瓶 · 环比 +18%')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Text('✅ 调价后将同步至官网与门店终端,请确认包装批次一致。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.bg)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('保存修改')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.primary)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct DeleteSalonModal {
  title: string = '';
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🥂')
            .fontSize(26)
          Column() {
            Text('取消沙龙')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('确认移除所选场次')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Text('沙龙名称:' + this.title)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.blush)
          .borderRadius(10)
          .margin({ top: 14 })

        Text('⚠️ 取消后已报名嘉宾将收到全额退款,请提前发布延期公告。')
          .fontSize(10)
          .fontColor(COLORS.warning)
          .width('100%')
          .margin({ top: 12 })

        Row() {
          Text('保留场次')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.bg)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('确认取消')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.danger)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.danger + '40' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct DetailCustomModal {
  name: string = '';
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('💝')
            .fontSize(26)
          Column() {
            Text('定制进度')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(this.name)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('香调方向')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('花香前调 · 檀木尾调')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 14 })

        Row() {
          Text('当前阶段')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('打样中 · 剩余 8 天')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('包装设计')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('鎏金瓶身 · 烫印姓名')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('专属服务')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('调香师 1v1 · 终身复刻')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Text('💝 定制香水将在交付前安排三版打样试香,确认后再定稿灌装。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('关闭')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.grape)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

结尾总结

在这里插入图片描述

通过对这套调香室应用源码的逐段拆解,我们可以从多个维度提炼出其架构设计的核心要点。

色彩体系维度,应用采用了"语义化集中管理"的策略。PerfumePalette 接口与 COLORS 常量将十九个色彩值集中定义,每个色彩都承载明确的语义——primary 代表品牌金色、grape 代表价值与主操作、danger 代表价格与警示、success 代表完成与积极、warning 代表进行中与提醒。这种"色彩即语义"的设计,使得色彩不仅是视觉装饰,更是信息编码的载体。更值得关注的是,应用通过十六进制 Alpha 通道后缀(如 ‘1F’、‘40’、‘66’)在运行时动态生成同色不同透明度的变体,无需额外定义浅色变量,极大简化了色彩体系的维护成本。

状态管理维度,应用建立了"三层状态"的分层架构。@Observed 类 PerfumeData 承载所有业务数据,实现深层响应式追踪;@State 管理入口组件的九个交互状态变量,涵盖分页索引、弹窗开关与动画触发器;@ObjectLink 在五个内容子组件中建立与父组件数据的双向绑定。这种分层策略确保了状态变更的精准触发——修改弹窗开关不会触发业务数据的重新渲染,修改业务数据不会触发弹窗状态的重置。回调函数作为跨组件通信的桥梁,从子组件向父组件冒泡事件,父组件统一调度弹窗的显示与参数传递,形成了清晰的单向数据流。

组件化维度,应用采用"入口+内容+标签+弹窗"的四层组件架构。入口组件 PerfumeApp 负责顶栏渲染、分页调度与弹窗挂载;五个 Content 组件分别承载五大业务模块的数据可视化与列表展示;PerfumeTag 作为通用标签在所有内容页面中复用;四个 Modal 组件以独立 struct 形式存在,通过条件渲染按需挂载。这种架构使得每个组件的职责边界清晰——Content 组件只负责展示,Modal 组件只负责交互,PerfumeTag 只负责标签渲染。当未来需要新增业务模块时,只需新增一个 Content 组件并在 TAB_LIST 中添加一项,入口组件的调度逻辑几乎无需修改。

数据可视化维度,应用展示了在 ArkTS 中实现多种图表的纯代码方案。周销量柱状图通过 ForEach+Column+高度按比例渲染实现;香调分布图、课程报名进度与沙龙热度排行通过双 Row 拼接的进度条实现——填充 Row 的 layoutWeight 为数据比例,未填充 Row 的 layoutWeight 为剩余比例。这些图表无需引入任何第三方图表库,纯声明式代码即实现了数据可视化,体现了 ArkTS 声明式 UI 范式的强大表现力。进度条的宽度策略也体现了对数据特性的理解——比例数据用弹性宽度,绝对数据用固定宽度。

交互设计维度,应用在顶栏实现了"情感化微交互"——花朵点击触发下落旋转动画,试管点击触发摇晃动画,通过 @State 布尔翻转驱动 animation 自动插值。这种"状态驱动动画"的模式是 ArkTS 声明式动画的核心。弹窗交互采用"条件渲染+回调关闭"的模式,弹窗只在需要时挂载,关闭时完全卸载,内存效率高。列表项的操作入口(如删除✕、编辑✎)使用灰色低调图标,降低误操作概率,同时配合确认弹窗实现二次保护。

工程规范维度,应用展现了多项值得借鉴的工程实践。枚举替代魔法数字提升可读性;ForEach 的键生成函数使用"名称+索引"避免重名冲突;字段值的色彩语义化着色使得文字不仅传达信息更传达状态;提示文字嵌入业务规范(如"气相色谱检测"“全额退款”“三版打样”)将操作指导融入交互流程;跨弹窗的结构一致性(标题区、字段行、按钮区)降低了用户的学习成本与开发者的维护成本。这些实践虽然单看微小,但累积起来构成了应用的整体品质感。

业务建模维度,数据模型的设计深刻反映了调香行业的业务逻辑。香材关注产地与使用频率,香水关注系列与评分,课程关注讲师与报名进度,沙龙关注主题与时间,定制关注客户与阶段——每个实体的字段都是对其业务核心关注点的精准映射。数据值的设计也极具真实感——檀香木 2180 元/7kg 反映稀缺性,年度新品发布 52 人参与反映活动热度,品牌调香营 2999 元/4 人报名反映高价小众。这种"数据即业务"的设计理念,使得应用不仅是技术展示,更像真实的运营工具。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐