香道铺非遗和香应用基于HarmonyOS API 24深度解析——采用了"单入口 + 多内容区 + 底部导航"的经典移动端布局模式。入口组件 IncenseApp 作为整个应用的根容器

本文基于 HarmonyOS NEXT + ArkTS 声明式开发框架,完整实现一个「香道铺」非遗和香主题 App,涵盖配色系统、状态管理、多 Tab 页面、自定义弹窗、动态颜色映射、列表竖条装饰等核心知识点,并附完整源码与运行效果截图。


一、引言:非遗和香的数字化传承

在移动互联网与智能设备高速发展的今天,非物质文化遗产的传承与传播已经从单纯的线下演示,演变为一种高度数字化、流程化、个性化的线上线下融合体验。和香,作为中国传统香文化的核心技艺,距今已有两千余年历史。一炷好香,从研磨、和香、窨藏、搓制到晾香、装匣,需经数十道工序,凝聚着匠人的心血与草木的温润。沉水香的醇厚、老山檀的悠远、艾草香的清苦、龙涎香的神秘,每一款香都承载着中国人对静与雅的追求与向往。然而,这门古老的技艺正面临着传承人老龄化、受众面狭窄、销售渠道单一等现实困境。

正是在这样的背景下,香道铺类数字化应用应运而生,并迅速成为非遗和香"活态传承"的数字载体。一个优秀的香道铺应用,不仅需要覆盖从"线香陈列"到"客评口碑"的完整业务闭环,还要在视觉设计、交互体验、数据呈现上做到细致入微,让用户在指尖滑动间便能感受到草木的温润与清烟的雅致。

本文将深入剖析一个基于鸿蒙 ArkUI 声明式开发框架构建的香道铺非遗和香应用。该应用以"线香陈列"为核心,横向扩展出香粉罐、香炉座、订单管理、客评口碑五大功能模块,形成了一个完整闭环的和香管理生态。它不仅是一个功能完备的演示项目,更是一份典型的 ArkUI 工程实践范本,涵盖了状态管理、组件化设计、模态弹窗、列表渲染、图表可视化、动态颜色映射、列表竖条装饰等关键技术点。

香道铺应用整体运行效果 - 本周线香销量柱状图与线香架列表


二、应用整体架构概览

整个应用采用了"单入口 + 多内容区 + 底部导航"的经典移动端布局模式。入口组件 IncenseApp 作为整个应用的根容器,负责管理当前激活的 Tab 状态,并根据状态切换不同的内容区组件。

应用一共划分为五个一级页面:线香陈列(STICK)、香粉罐(POWDER)、香炉座(BURNER)、订单管理(ORDER)以及客评口碑(REVIEW)。每个页面都是一个独立的 @Component 装饰的自定义组件,内部封装了自己的状态、构建逻辑和子组件。这种"高内聚、低耦合"的组件化设计,使得每个页面可以独立开发、独立测试,同时也便于后续的扩展与维护。

在数据层面,应用定义了一套完整的类型系统,包括色彩主题、线香元数据、香粉元数据、香炉元数据、订单元数据、客评元数据、周销量元数据、香型构成元数据、工艺热度元数据、TOP榜元数据等,并通过常量数组将数据与 UI 表现层解耦。所有业务数据以 @Observed 装饰的可观察类 IncenseData 为载体,配合五组各 12 条模拟数据,模拟了一个真实的香道铺数据环境。

在交互层面,应用通过条件渲染(if-else)实现了四类弹窗的叠加显示:新制线香、修改订单、撤下香粉、香炉详情。弹窗以全屏半透明遮罩 + 居中卡片的形式呈现,与主内容共享同一根 Column,通过布尔状态控制显隐。

在视觉层面,应用独创了"左侧竖条装饰"的列表卡片设计——每张卡片左侧都有一条 4px 宽的竖条,颜色通过动态颜色函数映射,模仿线香的形态,形成了强烈的视觉识别特征。

香道铺订单模块运行效果 - 制香工艺热度排行与香品订单列表(真机运行)

下面逐页展示应用的五大功能模块运行效果。


三、线香页:应用的核心陈列中枢

线香页是整个应用的默认首页,也是信息密度最高的页面之一。它包含了本周线香销量柱状图、线香架列表,以及新制线香弹窗。

线香页顶部是"本周线香销量"柱状图卡片,通过 ForEach 遍历一周七天的销量数据,柱子高度直接绑定销量数值,销量超过 45 的日期(周五、周六、周日)使用主色檀木棕 #7A5C3E 突出显示,其余日期使用 accent 竹叶绿 #6E8F5A,形成自然的高低起伏。周六以 58 盒的销量居首,符合线香作为雅器在周末热销的真实节奏。

下方是线香架列表,12 款线香卡片采用了独创的"左竖条 + 序号圆 + 右文字"横向布局。最左侧是一条 4px 宽、46px 高的竖条,颜色通过 getStickColor 函数按线香名称分档着色,模仿线香的形态,形成了强烈的视觉识别特征。竖条右侧是一个 32×32 的序号圆,显示 1-12 的序号,背景色同样按名称动态着色。右侧信息区展示线香名称、价格(通过动态颜色函数按价格分档着色)、香型标签、燃时标签(“燃4时”)和"手工搓制"提示文字。卡片背景使用白色与浅米色奇偶交替,提升长列表可读性。

12 款线香覆盖了从 48 元(藿香正)到 288 元(龙涎香)的完整价格梯队,香型涵盖木香、檀香、草木、花香、树脂、动物、辛香七大类,燃时从 1 时到 5 时不等,每款都配有独特的 emoji 图标(沉水香用🪵、老山檀用🌳、艾草香用🌿、龙涎香用🐋等),让线香不仅是焚香用品,更是嗅觉与视觉的双重体验。右上角 ➕ 按钮点击可弹出"新制线香"弹窗。


四、香粉页:销量排行与撤粉管理

香粉页以"香粉"为核心,顶部是线香销量 TOP6 横向条形图,下方是香粉罐列表,支持撤粉操作。

香粉模块 - 线香销量TOP6横向条形图与香粉罐列表

顶部 TOP6 卡片通过横向进度条展示销量排名前六的线香品种,沉水香以 96 的销量居首,老山檀、艾草香紧随其后。进度条使用 layoutWeight 比例法实现,已售部分与剩余部分按比例分配宽度,颜色直接取数据中的配置色,六种线香各有特色。

下方香粉罐列表展示 12 款香粉,卡片采用"左竖条 + 图标圆 + 右文字"布局。左侧竖条固定使用 getStickColor(‘沉水香’) 即檀木棕色,统一香粉页的视觉基调。竖条右侧是一个 34×34 的图标圆,展示香粉 emoji 图标,背景统一使用 accent 竹叶绿 14% 透明度。右侧展示香粉名称、颜色(浅棕/深褐/灰绿/金黄等)、产地与材质(“迈索尔 · 白檀心材”)和 🗑️ 撤粉按钮。

12 款香粉材质覆盖白檀心材、虫漏沉木、端午艾叶、金桂干花、茉莉花蕾、重瓣玫瑰、广藿香草、佩兰全草、白芷根条、公丁香蕾、薄荷叶尖、紫苏叶束十二种,产地从迈索尔、海南、蕲春、杭州、福州、平阴到广州、苏州、禹州、南宁、重庆、梅州,呈现出"香粉天下"的格局。颜色从浅棕、深褐、灰绿、金黄、米白、淡粉到深绿、草绿、米黄、棕红、青碧、紫褐,几乎囊括了天然香粉的全部色彩。点击 🗑️ 按钮弹出"撤下香粉"二次确认弹窗,采用警示型设计语言,"确认撤下"按钮使用危险色深褐红,防止误操作。


五、香炉页:香型构成与铸炉详情

香炉页以"香炉"为核心,顶部是香型构成占比图,下方是香炉座列表,支持查看详情。

香炉模块 - 香型构成占比图与香炉座列表

顶部香型构成卡片展示三种香型的分布占比:木香 5 味、草木 4 味、花香 3 味,合计 12 味,恰好对应线香陈列的香型总数。进度条以 12 为满刻度进行比例渲染,比百分比更直观地传达了"几香型"的语义。

下方香炉座列表展示 12 款香炉,卡片采用"左竖条 + 图标圆 + 右文字"布局。左侧竖条颜色通过 getBurnerColor 函数按匠级分数分档着色(≥95用檀木棕、≥92用竹叶绿、其余用金棕)。竖条右侧是一个 34×34 的图标圆,展示香炉 emoji 图标,背景色按匠级动态着色。右侧展示香炉名称、器型(山形/莲瓣/兽首/直耳等)、匠级评分(“匠级96”,颜色按匠级动态着色)、产地(“淄博 · 窑口”)和 👁️ 详情按钮。

12 款香炉器型覆盖山形、莲瓣、兽首、直耳、桥耳、鱼耳、三足、圆腹、鼓钉、朝冠、直筒、海棠十二种传统香炉器型,匠级从 89(筒式炉)到 96(博山炉、狮首炉)不等,博山炉和狮首炉以 96 级并列居首,符合"炉以博山为尊、以兽首为威"的行业共识。产地从淄博、景德镇、佛山、潮州、泉州、宁波、龙泉、宜兴、邯郸、大同、福州到苏州,几乎囊括了中国古代铸炉的全部名窑口。点击 👁️ 按钮弹出"香炉详情"弹窗,包含香炉展示区、文艺风格描述文案(“选良土制范,入窑素烧,再上釉复烧。炉盖镂空成山成莲,香烟自孔隙盘旋而出,如云如雾。炉腹宽而稳,承灰蓄火,一炉在手,静室生凉。”)、产地信息(“山东 · 淄博”)和匠人评级(“特级 · 制炉廿年”)。


六、订单页:制香工艺热度与订单改单

订单页以"订单"为核心,顶部是制香工艺热度排行,下方是订单列表,支持修改订单。

订单模块 - 制香工艺热度排行与香品订单列表

顶部制香工艺热度卡片展示六门工序的热度指数:研磨 96、和香 95、窨藏 94、搓制 92、晾香 91、装匣 89,恰好对应和香制作的完整流程(研磨→和香→窨藏→搓制→晾香→装匣),热度依次递减,呈现出"核心工序最热、收尾工序稍低"的合理分布。六根进度条颜色从檀木棕到竹叶绿到金棕到蓝色到浅灰到深褐红,形成了一条色彩渐变的工艺流水线。

下方订单列表展示 12 条订单,卡片采用"左竖条 + 图标圆 + 右文字"布局。左侧竖条颜色通过 getOrderColor 函数按订单金额分档着色(15万以上檀木棕、11-15万竹叶绿、8-11万金棕、8万以下蓝色)。竖条右侧是一个 32×32 的图标圆,展示客户 emoji 图标,背景色按订单金额动态着色。右侧展示客户名称、✏️ 编辑按钮、区域标签和订单详情(“· 620 件 · ¥124000”)。

12 类客户覆盖禅意茶空间、瑜伽静修馆、文创礼品公司、古琴雅集社、高端民宿群、书法工作室、SPA养生馆、汉服体验店、寺庙法物流通、香道培训学堂、月子护理中心、海外东方馆。寺庙法物流通以 2600 件的数量和 182000 元的金额双居首,海外东方馆虽然只有 320 件,但金额高达 164000,单价最高,体现了非遗和香在海外市场的高溢价。点击 ✏️ 按钮弹出"修改订单"弹窗,可调整数量和金额,数量行使用箭头符号"→"直观展示修改前后的对比。


七、客评页:口碑展示与标签体系

客评页是应用中最具"人情味"的页面,展示了 12 条用户好评。

客评模块 - 客评口碑列表

页面顶部是标题行"⭐ 客评口碑"和统计摘要"12条好评 · 平均4.9"。下方 12 条评价卡片采用"左头像右信息"的横向布局:最左侧是用户 emoji 头像,中间是用户名称和日期,右侧是星级评分(通过 String.repeat 一行代码生成对应数量的星星)和评价标签(通过动态颜色函数按标签语义分档着色)。评价内容正文在卡片下方,使用次要色文字和 16px 行高。

12 条评价每条都有独特的标签:“满室清润”、“打篆利落”、“静心驱蚊”、“客喜桂花”、“烟云绕梁”、“安睡整夜”、“东方草木”、“留香持久”、“打卡神器”、“定神之选”、“回订率高”、“五年老友”,这些标签既概括了评价核心,又形成了香道铺的口碑关键词云。

评价内容充满生活气息,如"沉水香一炷满室清润,客人总问香从哪来"、“香粉研磨极细,打香篆一次成型不断香”、“艾草香驱蚊又静心,写字时离不开它”、“博山炉烟气盘旋如云,配琴曲绝了”、“用了五年老铺香,炉子香品样样放心”,让数据看起来不是冷冰冰的测试桩,而是真实香友的心声。卡片背景使用白色与浅米色奇偶交替,整体视觉温馨而雅致。


八、色彩主题系统:檀木棕竹叶绿的和香视觉语言

8.1 色彩接口定义

任何一款设计精良的应用,背后都有一套严谨的色彩规范。该应用首先通过一个 IncensePalette 接口,将整个应用所需的色彩抽象为一个结构化的契约:

interface IncensePalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  cardAlt: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  wood: string;
  smokeWhite: string;
}
  • primary:主色调,檀木棕 #7A5C3E,用于按钮、强调文字、激活状态等高优先级视觉元素,是线香框料的标志性色彩。
  • primaryDark:主色深色变体 #3E2E1E,作为顶部头部背景,营造深棕香匣的沉稳感。
  • accent:强调色,竹叶绿 #6E8F5A,与檀木棕形成冷暖对比,用于草木香型、柱状图非高亮色等元素。
  • bg:页面整体背景色 #F7F4EE,米白暖调,营造宣纸般的温润质感。
  • wood:木色 #7A5C3E,专用于装饰竖条和头部装饰,语义上强调"木"的质感。
  • smokeWhite:烟白 #FBF9F4,用于装饰性背景,营造出香烟缭绕的柔和质感。

香道铺线香模块真机运行效果 - 檀木棕主色与竹叶绿强调色的视觉呈现

8.2 色彩实例化

const COLORS: IncensePalette = {
  primary: '#7A5C3E',
  primaryLight: '#C4A882',
  primaryDark: '#3E2E1E',
  accent: '#6E8F5A',
  accentLight: '#A8BF94',
  bg: '#F7F4EE',
  cardBg: '#FFFFFF',
  cardAlt: '#EFE9DE',
  textPrimary: '#38301F',
  textSecondary: '#7E7261',
  textHint: '#ABA08C',
  border: '#E2D8C6',
  line: '#EDE5D6',
  success: '#6E8F5A',
  warning: '#B0893F',
  danger: '#8E3B2E',
  white: '#FFFFFF',
  wood: '#7A5C3E',
  smokeWhite: '#FBF9F4'
};

主色 #7A5C3E 是一种醇厚的檀木棕,既契合"和香"主题(线香框料的标志性色彩),又具有传统、沉稳的气质。强调色 #6E8F5A 竹叶绿与檀木棕形成冷暖对比,呼应艾草、藿香等草木香的色彩。背景色 #F7F4EE 米白暖调与主色同暖色系,营造出宣纸般的温润氛围。这种将所有色彩集中管理的做法保证了全局一致性,支持主题切换,降低了维护成本。

香道铺香粉模块真机运行效果 - 米白背景与浅米色交替卡片的层次效果


九、类型定义:构建严谨的数据契约

应用通过一组接口定义,为每一类业务对象建立了明确的数据契约。

9.1 线香、香粉、香炉元数据

interface StickItem {
  name: string; scent: string; hours: number; price: number; emoji: string;
}
interface PowderItem {
  name: string; origin: string; material: string; color: string; emoji: string;
}
interface BurnerItem {
  name: string; shape: string; level: number; place: string; emoji: string;
}

StickItem 描述线香的名称、香型、燃时、价格和图标。PowderItem 描述香粉的名称、产地、材质、颜色和图标。BurnerItem 描述香炉的名称、器型、匠级评分、产地和图标,用 level 字段体现"以级定品"。

9.2 订单、客评与图表元数据

interface IncenseOrderItem {
  name: string; region: string; count: number; amount: number; emoji: string;
}
interface IncenseReviewItem {
  name: string; score: number; date: string; content: string; tag: string; emoji: string;
}
interface WeekStickMeta { day: string; value: number; }
interface ScentShareMeta { name: string; count: number; color: string; }
interface IncenseHotMeta { name: string; heat: number; color: string; }
interface IncenseTopMeta { name: string; sold: number; color: string; }

四组图表元数据(WeekStickMeta/ScentShareMeta/IncenseHotMeta/IncenseTopMeta)都自带 color 字段,实现了"数据即配色"的图表渲染模式,组件渲染时无需额外的颜色映射逻辑。


十、配置驱动:常量数据与可视化配置

10.1 Tab 导航配置

enum IncenseTab { STICK = 0, POWDER = 1, BURNER = 2, ORDER = 3, REVIEW = 4 }

const TAB_LIST: TabMeta[] = [
  { key: 'stick', icon: '🪵', label: '线香', color: '#7A5C3E' },
  { key: 'powder', icon: '🌾', label: '香粉', color: '#6E8F5A' },
  { key: 'burner', icon: '⚱️', label: '香炉', color: '#B0893F' },
  { key: 'order', icon: '📦', label: '订单', color: '#4E7BA8' },
  { key: 'review', icon: '⭐', label: '客评', color: '#7A5C3E' }
];

枚举提供类型安全的 Tab 标识,TAB_LIST 为每个 Tab 配置独立的图标、标签和主题色:线香用檀木棕、香粉用竹叶绿、香炉用金棕、订单用蓝色、客评用檀木棕,"一 Tab 一色"让每个页面有鲜明的视觉身份。

10.2 周销量、香型构成、工艺热度与 TOP 榜

const WEEK_SOLD = [
  { day: '周一', value: 14 }, { day: '周二', value: 19 },
  { day: '周三', value: 26 }, { day: '周四', value: 33 },
  { day: '周五', value: 46 }, { day: '周六', value: 58 }, { day: '周日', value: 51 }
];
const SCENT_SHARE = [
  { name: '木香', count: 5, color: '#7A5C3E' },
  { name: '草木', count: 4, color: '#6E8F5A' },
  { name: '花香', count: 3, color: '#B0893F' }
];
const CRAFT_HOT = [
  { name: '研磨', heat: 96 }, { name: '和香', heat: 95 }, { name: '窨藏', heat: 94 },
  { name: '搓制', heat: 92 }, { name: '晾香', heat: 91 }, { name: '装匣', heat: 89 }
];
const INCENSE_TOP = [
  { name: '沉水香', sold: 96 }, { name: '老山檀', sold: 93 },
  { name: '艾草香', sold: 90 }, { name: '桂花香', sold: 87 },
  { name: '梅花香', sold: 84 }, { name: '零陵香', sold: 81 }
];

CRAFT_HOT 的六门工艺恰好对应和香制作的完整流程(研磨→和香→窨藏→搓制→晾香→装匣),热度依次递减。INCENSE_COL 常量 [0…11] 用于顶部头部的装饰带渲染,按索引模 3 分别渲染:木色竖条 4×16(模0)、竹叶绿圆点 6×6(模1)、金棕方块 10×8(模2),形成一条高低错落的装饰线,呼应线香的"竖条"视觉元素。


十一、数据模型:可观察的线香实体

@Observed
export class IncenseData {
  sticks: StickItem[] = [ /* 12 款线香 */ ];
  powders: PowderItem[] = [ /* 12 款香粉 */ ];
  burners: BurnerItem[] = [ /* 12 款香炉 */ ];
  orders: IncenseOrderItem[] = [ /* 12 条订单 */ ];
  reviews: IncenseReviewItem[] = [ /* 12 条客评 */ ];
}

IncenseData 是核心数据模型,以五个数组承载香道铺全部业务信息。@Observed 装饰器使得该类实例在被 @State 或 @ObjectLink 引用时,属性变化能自动触发 UI 刷新。每个数组精确包含 12 条数据,"五五对称、各十二条"的设计保证了每个页面都有足够的内容密度。所有数据在类定义时直接初始化,"字段即默认值"的写法避免了构造函数中重复赋值。


十二、模拟数据:构建真实的香道铺数据环境

12.1 线香模拟数据

12 款线香覆盖了从 48 元(藿香正)到 288 元(龙涎香)的完整价格梯队,香型涵盖木香、檀香、草木、花香、树脂、动物、辛香七大类,燃时从 1 时到 5 时不等。沉水香¥168、老山檀¥148、艾草香¥58、桂花香¥78、梅花香¥88、茉莉香¥68、安息香¥128、龙涎香¥288、苏合香¥108、零陵香¥86、丁香香¥76、藿香正¥48,emoji 图标与线香名称呼应(沉水香用🪵、老山檀用🌳、艾草香用🌿、龙涎香用🐋等)。

12.2 香粉与香炉模拟数据

香粉材质覆盖白檀心材、虫漏沉木、端午艾叶、金桂干花、茉莉花蕾、重瓣玫瑰、广藿香草、佩兰全草、白芷根条、公丁香蕾、薄荷叶尖、紫苏叶束十二种,产地从迈索尔到梅州呈现出"香粉天下"的格局,颜色从浅棕到紫褐几乎囊括天然香粉全部色彩。香炉器型覆盖山形、莲瓣、兽首、直耳、桥耳、鱼耳、三足、圆腹、鼓钉、朝冠、直筒、海棠十二种传统器型,匠级从 89 到 96 不等,博山炉和狮首炉以 96 级并列居首,产地从淄博到苏州几乎囊括中国古代铸炉全部名窑口。

12.3 订单与客评模拟数据

订单覆盖 12 类客户,寺庙法物流通以 2600 件和 182000 元双居首,海外东方馆单价最高。客评 12 条各有独特标签(满室清润、打篆利落、静心驱蚊、客喜桂花、烟云绕梁、安睡整夜、东方草木、留香持久、打卡神器、定神之选、回订率高、五年老友),评价内容充满生活气息,如"沉水香一炷满室清润,客人总问香从哪来"、“用了五年老铺香,炉子香品样样放心”。


十三、动态颜色函数:数据驱动的视觉映射

应用通过五个动态颜色函数将业务数据映射为视觉色彩,实现"数据即配色"。

function getStickColor(name: string): string {
  if (name === '沉水香' || name === '老山檀' || name === '龙涎香') return '#7A5C3E';
  else if (name === '艾草香' || name === '藿香正' || name === '零陵香') return '#6E8F5A';
  else if (name === '桂花香' || name === '梅花香' || name === '茉莉香') return '#B0893F';
  return '#4E7BA8';
}
function getBurnerColor(level: number): string {
  if (level >= 95) return '#7A5C3E';
  else if (level >= 92) return '#6E8F5A';
  return '#B0893F';
}
function getPriceColor(price: number): string {
  if (price >= 160) return '#7A5C3E';
  else if (price >= 100) return '#6E8F5A';
  else if (price >= 70) return '#B0893F';
  return '#4E7BA8';
}
function getOrderColor(amount: number): string {
  if (amount >= 150000) return '#7A5C3E';
  else if (amount >= 110000) return '#6E8F5A';
  else if (amount >= 80000) return '#B0893F';
  return '#4E7BA8';
}
function getReviewTagColor(tag: string): string { /* 按标签语义分四档着色 */ }
  • getStickColor:按线香名称分四组色彩(沉水香/老山檀/龙涎香类檀木棕、艾草香/藿香正/零陵香类竹叶绿、桂花香/梅花香/茉莉香类金棕、其他蓝色)。
  • getBurnerColor:按匠级分数分三档(95以上檀木棕、92-94竹叶绿、其余金棕),“以色定级”。
  • getPriceColor:按价格分四档(160以上檀木棕、100-159竹叶绿、70-99金棕、70以下蓝色)。
  • getOrderColor:按订单金额分四档(15万以上檀木棕、11-15万竹叶绿、8-11万金棕、8万以下蓝色)。
  • getReviewTagColor:按评价标签语义分四色系。

值得注意的是,香粉页的左侧竖条固定调用 getStickColor('沉水香') 获取檀木棕色,统一了香粉页的视觉基调。而线香页、香炉页、订单页的左侧竖条则分别调用不同的动态颜色函数,实现了"每页一色基、每卡一色彩"的精细视觉控制。这种"左侧竖条 + 动态颜色"的组合是本应用最具辨识度的视觉设计特征。


十四、入口组件:应用的骨架与导航中枢

14.1 组件声明与状态

@Entry
@Component
struct IncenseApp {
  @State curTab: number = 0;
  @State data: IncenseData = new IncenseData();
  @State showAddStick: boolean = false;
  @State showEditOrder: boolean = false;
  @State showDeletePowder: boolean = false;
  @State showDetailBurner: boolean = false;
  @State delPowderName: string = '';
  @State detailBurnerName: string = '';
  @State lampSpin: boolean = false;
  @State smokeFloat: boolean = false;
}

10 个 @State 状态分四类:导航状态(curTab)、数据状态(data)、弹窗控制状态(4个布尔+2个参数字符串)、交互动效状态(lampSpin、smokeFloat)。

14.2 顶部头部构建

顶部头部采用 primaryDark #3E2E1E 深棕色背景。第一层标题栏左侧是标题"香道铺"和副标题"Incense · 一炷清烟 万虑皆空",右侧是动效区:油灯🪔点击触发 ±20 度旋转摆动(模拟灯火摇曳,duration 700ms)、清烟💨点击触发上下浮动动画(translate Y 从 2 到 -12,duration 800ms)、线香🪵静态装饰。第二层是"装饰带",通过 ForEach 遍历 INCENSE_COL,按模 3 渲染木色竖条 4×16、竹叶绿圆点 6×6、金棕方块 10×8,形成一条高低错落的装饰线,呼应线香的"竖条"视觉元素。第三层是标语分割线"💨 研磨窨藏 · 香入千门 ⚱️"。

14.3 底部 Tab 导航

底部导航顶部有一条 3px 高的檀木棕色装饰条,与深棕色头部形成上下呼应。每个 Tab 激活态有三重视觉反馈:标签文字主题色加粗、背景填充主题色 8% 透明度、主题色实线边框,圆角 8(香盒直方风格)。非激活态用灰色文字、透明背景、无边框。导航栏高度 60。

14.4 条件渲染弹窗

应用采用条件渲染(if-else)实现四个弹窗:新制线香(AddStickModal)、修改订单(EditIncenseOrderModal)、撤下香粉(DeletePowderModal)、香炉详情(DetailBurnerModal)。弹窗直接叠加在根 Column 末尾,通过布尔状态控制显隐,每个弹窗内部是全屏半透明遮罩(#66000000)+ 居中白色卡片。弹窗卡片统一使用圆角 14,信息行圆角 8。


十五、关键技术特性对比

特性维度 实现方式 技术要点 设计价值
色彩管理 IncensePalette 接口 + COLORS 常量 集中定义 20 种语义色,含 wood/smokeWhite 装饰色 保证视觉一致性,支持主题切换
数据建模 @Observed 装饰的 IncenseData 类 5 个数组各 12 条数据,覆盖和香全业务维度 数据与 UI 双向联动,响应式更新
配置驱动 常量数组 + 元数据接口 WEEK_SOLD/SCENT_SHARE/CRAFT_HOT/INCENSE_TOP 自带 color 数据与表现解耦,扩展零成本
动态颜色 5 个全局映射函数 按名称/匠级/价格/金额/标签分档着色,香粉页固定沉水香色 数据即配色,信息密度翻倍
列表竖条装饰 4px 竖条 + 动态颜色 线香/香炉/订单页左侧竖条颜色动态映射,模仿线香形态 强烈视觉识别,主题氛围浓
状态管理 @State + @ObjectLink 10 个状态分四类(导航/数据/弹窗/动效) 细粒度状态控制,自动触发渲染
组件化 @Component + @Builder 5 个页面组件 + 4 个弹窗组件 + IncenseTag 通用组件 高内聚低耦合,代码复用率高
模态交互 条件渲染弹窗 + 全屏遮罩 4 个弹窗 if-else 叠加,#66000000 半透明遮罩 灵活可控,完全自定义布局
列表渲染 ForEach + 奇偶交替背景 12 条数据 × 5 页面,cardBg/cardAlt 交替 数据驱动渲染,长列表可读性强
数据可视化 柱状图 + 横向条形图 height 绑定数据、layoutWeight 比例法、阈值着色 多维度呈现,直观易读
星级渲染 String.repeat 技巧 ‘⭐’.repeat(score) 一行代码生成星级 简洁高效,无需循环
防御编程 字段默认值 + 类型安全 所有数据字段声明即初始化,枚举替代魔法数字 健壮性强,无运行时异常
布局系统 Column/Row + layoutWeight 弹性权重分配、条件渲染弹窗、Scroll 滚动容器 自适应布局,层次分明
交互动效 rotate + translate + animation 油灯摆动±20度 700ms、清烟浮动 800ms、EaseOut 曲线 静态头部灵动化,趣味性强
视觉层次 装饰带/圆角8/竖条装饰 INCENSE_COL 模3渲染高低错落装饰、卡片圆角8、4px竖条 精致细腻,香道氛围感强

完整代码

interface IncensePalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  cardAlt: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  wood: string;
  smokeWhite: string;
}

const COLORS: IncensePalette = {
  primary: '#7A5C3E',
  primaryLight: '#C4A882',
  primaryDark: '#3E2E1E',
  accent: '#6E8F5A',
  accentLight: '#A8BF94',
  bg: '#F7F4EE',
  cardBg: '#FFFFFF',
  cardAlt: '#EFE9DE',
  textPrimary: '#38301F',
  textSecondary: '#7E7261',
  textHint: '#ABA08C',
  border: '#E2D8C6',
  line: '#EDE5D6',
  success: '#6E8F5A',
  warning: '#B0893F',
  danger: '#8E3B2E',
  white: '#FFFFFF',
  wood: '#7A5C3E',
  smokeWhite: '#FBF9F4'
};

enum IncenseTab {
  STICK = 0,
  POWDER = 1,
  BURNER = 2,
  ORDER = 3,
  REVIEW = 4
}

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

const TAB_LIST: TabMeta[] = [
  { key: 'stick', icon: '🪵', label: '线香', color: '#7A5C3E' },
  { key: 'powder', icon: '🌾', label: '香粉', color: '#6E8F5A' },
  { key: 'burner', icon: '⚱️', label: '香炉', color: '#B0893F' },
  { key: 'order', icon: '📦', label: '订单', color: '#4E7BA8' },
  { key: 'review', icon: '⭐', label: '客评', color: '#7A5C3E' }
];

const INCENSE_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];

interface StickItem {
  name: string;
  scent: string;
  hours: number;
  price: number;
  emoji: string;
}

interface PowderItem {
  name: string;
  origin: string;
  material: string;
  color: string;
  emoji: string;
}

interface BurnerItem {
  name: string;
  shape: string;
  level: number;
  place: string;
  emoji: string;
}

interface IncenseOrderItem {
  name: string;
  region: string;
  count: number;
  amount: number;
  emoji: string;
}

interface IncenseReviewItem {
  name: string;
  score: number;
  date: string;
  content: string;
  tag: string;
  emoji: string;
}

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

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

interface IncenseHotMeta {
  name: string;
  heat: number;
  color: string;
}

interface IncenseTopMeta {
  name: string;
  sold: number;
  color: string;
}

const WEEK_SOLD: WeekStickMeta[] = [
  { day: '周一', value: 14 },
  { day: '周二', value: 19 },
  { day: '周三', value: 26 },
  { day: '周四', value: 33 },
  { day: '周五', value: 46 },
  { day: '周六', value: 58 },
  { day: '周日', value: 51 }
];

const SCENT_SHARE: ScentShareMeta[] = [
  { name: '木香', count: 5, color: '#7A5C3E' },
  { name: '草木', count: 4, color: '#6E8F5A' },
  { name: '花香', count: 3, color: '#B0893F' }
];

const CRAFT_HOT: IncenseHotMeta[] = [
  { name: '研磨', heat: 96, color: '#7A5C3E' },
  { name: '和香', heat: 95, color: '#6E8F5A' },
  { name: '窨藏', heat: 94, color: '#B0893F' },
  { name: '搓制', heat: 92, color: '#4E7BA8' },
  { name: '晾香', heat: 91, color: '#7C8AA0' },
  { name: '装匣', heat: 89, color: '#8E3B2E' }
];

const INCENSE_TOP: IncenseTopMeta[] = [
  { name: '沉水香', sold: 96, color: '#7A5C3E' },
  { name: '老山檀', sold: 93, color: '#6E8F5A' },
  { name: '艾草香', sold: 90, color: '#B0893F' },
  { name: '桂花香', sold: 87, color: '#7C8AA0' },
  { name: '梅花香', sold: 84, color: '#4E7BA8' },
  { name: '零陵香', sold: 81, color: '#8E3B2E' }
];

@Observed
export class IncenseData {
  sticks: StickItem[] = [
    { name: '沉水香', scent: '木香', hours: 4, price: 168, emoji: '🪵' },
    { name: '老山檀', scent: '檀香', hours: 3, price: 148, emoji: '🌳' },
    { name: '艾草香', scent: '草木', hours: 2, price: 58, emoji: '🌿' },
    { name: '桂花香', scent: '花香', hours: 2, price: 78, emoji: '🍂' },
    { name: '梅花香', scent: '花香', hours: 3, price: 88, emoji: '🌸' },
    { name: '茉莉香', scent: '花香', hours: 2, price: 68, emoji: '🤍' },
    { name: '安息香', scent: '树脂', hours: 4, price: 128, emoji: '🍯' },
    { name: '龙涎香', scent: '动物', hours: 5, price: 288, emoji: '🐋' },
    { name: '苏合香', scent: '树脂', hours: 3, price: 108, emoji: '🍚' },
    { name: '零陵香', scent: '草木', hours: 2, price: 86, emoji: '🌾' },
    { name: '丁香香', scent: '辛香', hours: 2, price: 76, emoji: '🌸' },
    { name: '藿香正', scent: '草木', hours: 1, price: 48, emoji: '🍃' }
  ];

  powders: PowderItem[] = [
    { name: '檀香粉', origin: '迈索尔', material: '白檀心材', color: '浅棕', emoji: '🟤' },
    { name: '沉香粉', origin: '海南', material: '虫漏沉木', color: '深褐', emoji: '🟫' },
    { name: '艾绒粉', origin: '蕲春', material: '端午艾叶', color: '灰绿', emoji: '🟢' },
    { name: '桂花粉', origin: '杭州', material: '金桂干花', color: '金黄', emoji: '🟡' },
    { name: '茉莉粉', origin: '福州', material: '茉莉花蕾', color: '米白', emoji: '⚪' },
    { name: '玫瑰粉', origin: '平阴', material: '重瓣玫瑰', color: '淡粉', emoji: '🩷' },
    { name: '藿香粉', origin: '广州', material: '广藿香草', color: '深绿', emoji: '💚' },
    { name: '佩兰粉', origin: '苏州', material: '佩兰全草', color: '草绿', emoji: '🌱' },
    { name: '白芷粉', origin: '禹州', material: '白芷根条', color: '米黄', emoji: '🟨' },
    { name: '丁香粉', origin: '南宁', material: '公丁香蕾', color: '棕红', emoji: '🟥' },
    { name: '薄荷粉', origin: '重庆', material: '薄荷叶尖', color: '青碧', emoji: '🧊' },
    { name: '紫苏粉', origin: '梅州', material: '紫苏叶束', color: '紫褐', emoji: '🟪' }
  ];

  burners: BurnerItem[] = [
    { name: '博山炉', shape: '山形', level: 96, place: '淄博', emoji: '⛰️' },
    { name: '莲花炉', shape: '莲瓣', level: 94, place: '景德镇', emoji: '🪷' },
    { name: '狮首炉', shape: '兽首', level: 95, place: '佛山', emoji: '🦁' },
    { name: '冲天炉', shape: '直耳', level: 93, place: '潮州', emoji: '⬆️' },
    { name: '桥耳炉', shape: '桥耳', level: 94, place: '泉州', emoji: '🌉' },
    { name: '鱼耳炉', shape: '鱼耳', level: 92, place: '宁波', emoji: '🐟' },
    { name: '鬲式炉', shape: '三足', level: 93, place: '龙泉', emoji: '🔺' },
    { name: '簋式炉', shape: '圆腹', level: 91, place: '宜兴', emoji: '🏺' },
    { name: '鼓钉炉', shape: '鼓钉', level: 92, place: '邯郸', emoji: '🥁' },
    { name: '朝冠炉', shape: '朝冠', level: 90, place: '大同', emoji: '👑' },
    { name: '筒式炉', shape: '直筒', level: 89, place: '福州', emoji: '🛢️' },
    { name: '海棠炉', shape: '海棠', level: 95, place: '苏州', emoji: '🌺' }
  ];

  orders: IncenseOrderItem[] = [
    { name: '禅意茶空间', region: '华东', count: 620, amount: 124000, emoji: '🍵' },
    { name: '瑜伽静修馆', region: '华北', count: 480, amount: 96000, emoji: '🧘' },
    { name: '文创礼品公司', region: '华南', count: 1800, amount: 156000, emoji: '🎁' },
    { name: '古琴雅集社', region: '西南', count: 360, amount: 84000, emoji: '🎼' },
    { name: '高端民宿群', region: '华东', count: 740, amount: 118000, emoji: '🏡' },
    { name: '书法工作室', region: '华中', count: 520, amount: 72000, emoji: '🖌️' },
    { name: 'SPA养生馆', region: '华南', count: 960, amount: 138000, emoji: '💆' },
    { name: '汉服体验店', region: '华东', count: 440, amount: 66000, emoji: '👘' },
    { name: '寺庙法物流通', region: '华北', count: 2600, amount: 182000, emoji: '🛕' },
    { name: '香道培训学堂', region: '西南', count: 380, amount: 92000, emoji: '📚' },
    { name: '月子护理中心', region: '华中', count: 580, amount: 78000, emoji: '👶' },
    { name: '海外东方馆', region: '海外', count: 320, amount: 164000, emoji: '🌏' }
  ];

  reviews: IncenseReviewItem[] = [
    { name: '茶空间主理', score: 5, date: '09-04', content: '沉水香一炷满室清润,客人总问香从哪来。', tag: '满室清润', emoji: '🍵' },
    { name: '香道学员', score: 5, date: '09-03', content: '香粉研磨极细,打香篆一次成型不断香。', tag: '打篆利落', emoji: '📚' },
    { name: '书法老师', score: 5, date: '09-02', content: '艾草香驱蚊又静心,写字时离不开它。', tag: '静心驱蚊', emoji: '🖌️' },
    { name: '民宿管家', score: 4, date: '09-01', content: '桂花香受欢迎,若出大罐装更省事。', tag: '客喜桂花', emoji: '🏡' },
    { name: '古琴师', score: 5, date: '08-31', content: '博山炉烟气盘旋如云,配琴曲绝了。', tag: '烟云绕梁', emoji: '🎼' },
    { name: '宝妈用户', score: 5, date: '08-30', content: '艾草香给娃房间用,一夜安睡到天亮。', tag: '安睡整夜', emoji: '👶' },
    { name: '海外买家', score: 5, date: '08-29', content: '漂洋过海香味不散,东方草木气息动人。', tag: '东方草木', emoji: '🌏' },
    { name: 'SPA店主', score: 4, date: '08-28', content: '老山檀留香持久,就是旺季常常断货。', tag: '留香持久', emoji: '💆' },
    { name: '汉服掌柜', score: 5, date: '08-27', content: '海棠炉配马面裙拍照,客人排队打卡。', tag: '打卡神器', emoji: '👘' },
    { name: '禅修导师', score: 5, date: '08-26', content: '零陵香定神最快,共修时人手一盒。', tag: '定神之选', emoji: '🧘' },
    { name: '礼品采购', score: 5, date: '08-25', content: '节日礼盒包装雅致,客户回订率极高。', tag: '回订率高', emoji: '🎁' },
    { name: '老香友', score: 5, date: '08-24', content: '用了五年老铺香,炉子香品样样放心。', tag: '五年老友', emoji: '🪵' }
  ];
}

function getStickColor(name: string): string {
  if (name === '沉水香' || name === '老山檀' || name === '龙涎香') {
    return '#7A5C3E';
  } else if (name === '艾草香' || name === '藿香正' || name === '零陵香') {
    return '#6E8F5A';
  } else if (name === '桂花香' || name === '梅花香' || name === '茉莉香') {
    return '#B0893F';
  }
  return '#4E7BA8';
}

function getBurnerColor(level: number): string {
  if (level >= 95) {
    return '#7A5C3E';
  } else if (level >= 92) {
    return '#6E8F5A';
  }
  return '#B0893F';
}

function getPriceColor(price: number): string {
  if (price >= 160) {
    return '#7A5C3E';
  } else if (price >= 100) {
    return '#6E8F5A';
  } else if (price >= 70) {
    return '#B0893F';
  }
  return '#4E7BA8';
}

function getOrderColor(amount: number): string {
  if (amount >= 150000) {
    return '#7A5C3E';
  } else if (amount >= 110000) {
    return '#6E8F5A';
  } else if (amount >= 80000) {
    return '#B0893F';
  }
  return '#4E7BA8';
}

function getReviewTagColor(tag: string): string {
  if (tag === '满室清润' || tag === '烟云绕梁' || tag === '东方草木' || tag === '五年老友') {
    return '#7A5C3E';
  } else if (tag === '打篆利落' || tag === '静心驱蚊' || tag === '安睡整夜' || tag === '定神之选') {
    return '#6E8F5A';
  } else if (tag === '客喜桂花' || tag === '留香持久' || tag === '打卡神器') {
    return '#B0893F';
  }
  return '#4E7BA8';
}

@Entry
@Component
struct IncenseApp {
  @State curTab: number = 0;
  @State data: IncenseData = new IncenseData();
  @State showAddStick: boolean = false;
  @State showEditOrder: boolean = false;
  @State showDeletePowder: boolean = false;
  @State showDetailBurner: boolean = false;
  @State delPowderName: string = '';
  @State detailBurnerName: string = '';
  @State lampSpin: boolean = false;
  @State smokeFloat: 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('Incense · 一炷清烟 万虑皆空')
                .fontSize(10)
                .fontColor(COLORS.primaryLight)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Row() {
              Text('🪔')
                .fontSize(18)
                .onClick(() => {
                  this.lampSpin = !this.lampSpin;
                })
                .rotate({ angle: this.lampSpin ? 20 : -20 })
                .animation({ duration: 700, curve: Curve.EaseOut })
              Text('💨')
                .fontSize(16)
                .margin({ left: 10 })
                .onClick(() => {
                  this.smokeFloat = !this.smokeFloat;
                })
                .translate({ x: 0, y: this.smokeFloat ? -12 : 2 })
                .animation({ duration: 800, curve: Curve.EaseOut })
              Text('🪵')
                .fontSize(14)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(COLORS.primaryLight + '2E')
            .borderRadius(8)
            .border({ width: 1, color: COLORS.primaryLight + '80' })
          }
          .width('100%')

          Row() {
            ForEach(INCENSE_COL, (r: number) => {
              Row() {
                if (r % 3 === 0) {
                  Column()
                    .width(4)
                    .height(16)
                    .backgroundColor(COLORS.wood)
                    .borderRadius(2)
                } else if (r % 3 === 1) {
                  Column()
                    .width(6)
                    .height(6)
                    .backgroundColor(COLORS.accent)
                    .borderRadius(3)
                } else {
                  Column()
                    .width(10)
                    .height(8)
                    .backgroundColor(COLORS.warning)
                    .borderRadius(2)
                }
              }
              .width(14)
              .justifyContent(FlexAlign.Center)
            }, (r: number) => 'incense' + r)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ top: 12 })

          Row() {
            Column()
              .layoutWeight(1)
              .height(1)
              .backgroundColor(COLORS.primaryLight + '66')
            Text('💨 研磨窨藏 · 香入千门 ⚱️')
              .fontSize(9)
              .fontColor(COLORS.primaryLight)
              .margin({ left: 8, right: 8 })
            Column()
              .layoutWeight(1)
              .height(1)
              .backgroundColor(COLORS.primaryLight + '66')
          }
          .width('100%')
          .margin({ top: 10 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.primaryDark)

        if (this.curTab === IncenseTab.STICK) {
          StickContent({ data: this.data, onAdd: () => {
            this.showAddStick = true;
          } })
        } else if (this.curTab === IncenseTab.POWDER) {
          PowderContent({ data: this.data, onDel: (n: string) => {
            this.delPowderName = n;
            this.showDeletePowder = true;
          } })
        } else if (this.curTab === IncenseTab.BURNER) {
          BurnerContent({ data: this.data, onDetail: (n: string) => {
            this.detailBurnerName = n;
            this.showDetailBurner = true;
          } })
        } else if (this.curTab === IncenseTab.ORDER) {
          IncenseOrderContent({ data: this.data, onEdit: () => {
            this.showEditOrder = true;
          } })
        } else {
          IncenseReviewContent({ data: this.data })
        }
      }
      .width('100%')
      .height('100%')

      Column() {
        Column()
          .width('100%')
          .height(3)
          .backgroundColor(COLORS.primary)
        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: 7, bottom: 7 })
            .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? t.color + '14' : '#00000000')
            .borderRadius(8)
            .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color } : { width: 0 })
            .onClick(() => {
              this.curTab = TAB_LIST.indexOf(t);
            })
          }, (t: TabMeta) => t.key)
        }
        .width('100%')
        .height(60)
        .padding({ left: 8, right: 8 })
        .backgroundColor(COLORS.cardBg)
      }
      .width('100%')

      if (this.showAddStick) {
        AddStickModal({
          onClose: () => {
            this.showAddStick = false;
          }
        })
      }
      if (this.showEditOrder) {
        EditIncenseOrderModal({
          onClose: () => {
            this.showEditOrder = false;
          }
        })
      }
      if (this.showDeletePowder) {
        DeletePowderModal({
          title: this.delPowderName, onClose: () => {
            this.showDeletePowder = false;
          }
        })
      }
      if (this.showDetailBurner) {
        DetailBurnerModal({
          name: this.detailBurnerName, onClose: () => {
            this.showDetailBurner = false;
          }
        })
      }
    }
  }
}

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

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

@Component
struct StickContent {
  @ObjectLink data: IncenseData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 本周线香销量')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六58盒')
              .fontSize(9)
              .fontColor(COLORS.primary)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_SOLD, (w: WeekStickMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 45 ? COLORS.primary : COLORS.accent)
                Column()
                  .width(16)
                  .height(w.value)
                  .backgroundColor(w.value >= 45 ? COLORS.primary : COLORS.accent)
                  .borderRadius(4)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekStickMeta) => w.day)
          }
          .width('100%')
          .height(150)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .border({ width: 1, color: COLORS.accent + '55' })

        Column() {
          Row() {
            Text('🪵 线香架')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕添香')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(14)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.sticks, (p: StickItem, i: number) => {
            Row() {
              Column()
                .width(4)
                .height(46)
                .backgroundColor(getStickColor(p.name))
                .borderRadius(2)

              Column() {
                Text(i + 1 + '')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(getStickColor(p.name))
              }
              .width(32)
              .height(32)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .backgroundColor(getStickColor(p.name) + '14')
              .borderRadius(6)
              .border({ width: 1, color: getStickColor(p.name) + '55' })
              .margin({ left: 8 })

              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text('¥' + p.price)
                    .fontSize(10)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(getPriceColor(p.price))
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                Row() {
                  IncenseTag({ text: p.scent + '香', color: getStickColor(p.name) })
                  IncenseTag({ text: '燃' + p.hours + '时', color: COLORS.accent })
                  Text('手工搓制')
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: StickItem, i: number) => 'st' + p.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

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

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🏆 线香销量 TOP6')
              .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(INCENSE_TOP, (t: IncenseTopMeta) => {
            Row() {
              Text(t.name)
                .fontSize(10)
                .width(64)
                .fontColor(COLORS.textPrimary)
              Row() {
                Row()
                  .layoutWeight(t.sold)
                  .height(12)
                  .backgroundColor(t.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(100 - t.sold)
                  .height(12)
                  .backgroundColor(COLORS.line)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(12)
              .margin({ left: 6 })
              Text(t.sold + '')
                .fontSize(10)
                .width(28)
                .textAlign(TextAlign.End)
                .fontColor(t.color)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (t: IncenseTopMeta) => t.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .border({ width: 1, color: COLORS.accent + '55' })

        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.powders, (p: PowderItem, i: number) => {
            Row() {
              Column()
                .width(4)
                .height(40)
                .backgroundColor(getStickColor('沉水香'))
                .borderRadius(2)

              Column() {
                Text(p.emoji)
                  .fontSize(16)
                  .textAlign(TextAlign.Center)
              }
              .width(34)
              .height(34)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .backgroundColor(COLORS.accent + '14')
              .borderRadius(6)
              .border({ width: 1, color: COLORS.accent + '55' })
              .margin({ left: 8 })

              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(p.color)
                    .fontSize(9)
                    .fontColor(COLORS.textSecondary)
                    .margin({ left: 8 })
                  Text('🗑️')
                    .fontSize(12)
                    .margin({ left: 8 })
                    .onClick(() => {
                      this.onDel(p.name);
                    })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                Row() {
                  Text(p.origin + ' · ' + p.material)
                    .fontSize(9)
                    .fontColor(COLORS.accent)
                  Text('香粉')
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: PowderItem, i: number) => 'pw' + p.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct BurnerContent {
  @ObjectLink data: IncenseData;
  onDetail: (n: string) => 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(SCENT_SHARE, (s: ScentShareMeta) => {
            Row() {
              Text(s.name)
                .fontSize(10)
                .width(40)
                .fontColor(COLORS.textPrimary)
              Row() {
                Row()
                  .layoutWeight(s.count)
                  .height(14)
                  .backgroundColor(s.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(12 - s.count)
                  .height(14)
                  .backgroundColor(COLORS.line)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(14)
              .margin({ left: 6 })
              Text(s.count + ' 味')
                .fontSize(10)
                .width(36)
                .textAlign(TextAlign.End)
                .fontColor(s.color)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (s: ScentShareMeta) => s.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .border({ width: 1, color: COLORS.accent + '55' })

        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.burners, (p: BurnerItem, i: number) => {
            Row() {
              Column()
                .width(4)
                .height(40)
                .backgroundColor(getBurnerColor(p.level))
                .borderRadius(2)

              Column() {
                Text(p.emoji)
                  .fontSize(16)
                  .textAlign(TextAlign.Center)
              }
              .width(34)
              .height(34)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .backgroundColor(getBurnerColor(p.level) + '14')
              .borderRadius(6)
              .border({ width: 1, color: getBurnerColor(p.level) + '55' })
              .margin({ left: 8 })

              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(p.shape)
                    .fontSize(9)
                    .fontColor(COLORS.textSecondary)
                    .margin({ left: 8 })
                  Text('👁️')
                    .fontSize(12)
                    .margin({ left: 8 })
                    .onClick(() => {
                      this.onDetail(p.name);
                    })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                Row() {
                  Text('匠级' + p.level)
                    .fontSize(9)
                    .fontColor(getBurnerColor(p.level))
                  Text(p.place + ' · 窑口')
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: BurnerItem, i: number) => 'bn' + p.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct IncenseOrderContent {
  @ObjectLink data: IncenseData;
  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(CRAFT_HOT, (h: IncenseHotMeta) => {
            Row() {
              Text(h.name)
                .fontSize(10)
                .width(52)
                .fontColor(COLORS.textPrimary)
              Row() {
                Row()
                  .layoutWeight(h.heat)
                  .height(10)
                  .backgroundColor(h.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(100 - h.heat)
                  .height(10)
                  .backgroundColor(COLORS.line)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(10)
              .margin({ left: 6 })
              Text(h.heat + '')
                .fontSize(10)
                .width(28)
                .textAlign(TextAlign.End)
                .fontColor(h.color)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (h: IncenseHotMeta) => h.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .border({ width: 1, color: COLORS.accent + '55' })

        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.orders, (o: IncenseOrderItem, i: number) => {
            Row() {
              Column()
                .width(4)
                .height(38)
                .backgroundColor(getOrderColor(o.amount))
                .borderRadius(2)

              Column() {
                Text(o.emoji)
                  .fontSize(15)
                  .textAlign(TextAlign.Center)
              }
              .width(32)
              .height(32)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .backgroundColor(getOrderColor(o.amount) + '14')
              .borderRadius(6)
              .border({ width: 1, color: getOrderColor(o.amount) + '55' })
              .margin({ left: 8 })

              Column() {
                Row() {
                  Text(o.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text('✏️')
                    .fontSize(12)
                    .margin({ left: 8 })
                    .onClick(() => {
                      this.onEdit();
                    })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                Row() {
                  Text(o.region)
                    .fontSize(9)
                    .fontColor(getOrderColor(o.amount))
                  Text('· ' + o.count + ' 件 · ¥' + o.amount)
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                    .margin({ left: 6 })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (o: IncenseOrderItem, i: number) => 'od' + o.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct IncenseReviewContent {
  @ObjectLink data: IncenseData;

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('⭐ 客评口碑')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('12条好评 · 平均4.9')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 6 })
        ForEach(this.data.reviews, (r: IncenseReviewItem, i: number) => {
          Column() {
            Row() {
              Text(r.emoji)
                .fontSize(15)
              Column() {
                Text(r.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(r.date)
                  .fontSize(9)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 1 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 8 })
              Text('⭐'.repeat(r.score))
                .fontSize(10)
                .fontColor(COLORS.warning)
              IncenseTag({ text: r.tag, color: getReviewTagColor(r.tag) })
            }
            .width('100%')
            Text(r.content)
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .lineHeight(16)
              .margin({ top: 6 })
          }
          .width('100%')
          .padding(12)
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 8 })
        }, (r: IncenseReviewItem, i: number) => 'rv' + r.name + i)
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

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

  build() {
    Column() {
      Column() {
        Row() {
          Text('🪵 新制线香')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Row() {
          Text('品名')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('雪中春信')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 14 })
        Row() {
          Text('香型')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('梅蕊寒香 · 窨藏三旬 · 手工搓制')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })
        Row() {
          Text('定价')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('¥96')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })
        Row() {
          Text('取消')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(10)
            .border({ width: 1, color: COLORS.border })
            .onClick(() => {
              this.onClose();
            })
          Text('确认添香')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .backgroundColor(COLORS.primary)
            .borderRadius(10)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 14 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

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

  build() {
    Column() {
      Column() {
        Row() {
          Text('✏️ 修改订单')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Row() {
          Text('客户')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('寺庙法物流通')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 14 })
        Row() {
          Text('数量')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('2600 → 3200 件')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })
        Row() {
          Text('金额')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('¥224000')
            .fontSize(11)
            .fontColor(COLORS.accent)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })
        Row() {
          Text('取消')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(10)
            .border({ width: 1, color: COLORS.border })
            .onClick(() => {
              this.onClose();
            })
          Text('保存修改')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .backgroundColor(COLORS.primary)
            .borderRadius(10)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 14 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct DeletePowderModal {
  @Prop title: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Text('🗑️ 撤下香粉')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('确定撤下「' + this.title + '」吗?')
          .fontSize(12)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 10 })
        Text('撤下的香粉将从香粉罐移除。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .margin({ top: 4 })
        Row() {
          Text('再想想')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(10)
            .border({ width: 1, color: COLORS.border })
            .onClick(() => {
              this.onClose();
            })
          Text('确认撤下')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .backgroundColor(COLORS.danger)
            .borderRadius(10)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 16 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct DetailBurnerModal {
  @Prop name: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('👁️ 香炉详情')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Column() {
          Text('⚱️')
            .fontSize(30)
          Text(this.name)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ top: 6 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(10)
        .margin({ top: 12 })
        Text('选良土制范,入窑素烧,再上釉复烧。炉盖镂空成山成莲,香烟自孔隙盘旋而出,如云如雾。炉腹宽而稳,承灰蓄火,一炉在手,静室生凉。')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .lineHeight(17)
          .margin({ top: 10 })
        Row() {
          Text('窑口产地')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('山东 · 淄博')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })
        Row() {
          Text('匠人评级')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('特级 · 制炉廿年')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })
        Text('知道了')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 18, right: 18, top: 7, bottom: 7 })
          .backgroundColor(COLORS.primary)
          .borderRadius(10)
          .margin({ top: 12 })
          .onClick(() => {
            this.onClose();
          })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

十六、开发环境搭建

16.1 安装 DevEco Studio

DevEco Studio 是 HarmonyOS 应用开发的官方 IDE。下载安装包后双击运行,进入安装欢迎界面,点击「下一步」继续。

DevEco Studio 安装欢迎界面

16.2 选择安装路径

选择目标安装目录,建议安装到非系统盘,确认所需空间后点击「下一步」。

选择安装位置

16.3 配置安装选项

设置环境变量,勾选创建桌面快捷方式、将 bin 文件夹添加到 PATH、添加上下文菜单,但是需要重启一下电脑使环境变量生效。

安装选项配置

16.4 选择项目模板

新建一个空白模板,在模板市场中选择 Empty Ability。

选择 Empty Ability 模板

16.5 配置项目信息

设置 API 为 24 的模板项目,项目名称 kfschool,Compatible SDK 6.1.1(24),设备类型勾选 Phone,点击完成后初始化项目,自动下载相关依赖。

配置项目信息


十七、总结

通过对这款香道铺非遗和香应用的深度剖析,我们可以看到,一个优秀的移动端应用远不止是功能的堆砌,它更是架构设计、数据建模、视觉系统、交互逻辑、防御性编程等多维度能力的综合体现。

在架构层面,该应用采用了"配置驱动 + 组件化 + 状态管理"的三层设计模式。配置层将所有可变的数据与视觉属性抽离为常量数组和元数据接口,使得业务逻辑与表现层彻底解耦;组件层将五个功能页面拆分为独立的 @Component,每个组件内部通过 @Builder 进一步复用 UI 片段;状态层利用 ArkUI 的 @State、@Observed 和 @ObjectLink 机制,实现了数据变化到 UI 刷新的自动响应。

在数据层面,IncenseData 类作为核心数据模型,以 5 个数组各 12 条数据完整描述了香道铺的全部业务信息。5 个动态颜色映射函数将业务数据转化为视觉色彩,实现了"数据即配色"的高级渲染模式,这是该应用最具特色的技术亮点之一。

在视觉层面,本应用独创了"左侧竖条装饰"的列表卡片设计——每张卡片左侧都有一条 4px 宽的竖条,颜色通过动态颜色函数映射,模仿线香的形态,形成了强烈的视觉识别特征。线香页还加入了序号圆设计,1-12 的序号圆配合左侧竖条,让列表呈现出"一炷一序号"的仪式感。统一的色彩系统是整个应用的视觉基石,檀木棕主色搭配竹叶绿强调色,配合金棕语义色和蓝色装饰色,营造出既传统又雅致的和香主题氛围。顶部头部的高低错落装饰带、油灯摆动+清烟浮动微动效、8px 圆角等细节处理,使得界面在信息密度较高的同时依然保持精致与舒适,处处透露出"香道铺"的主题气质。

在交互层面,应用实现了完整的 CRUD 闭环——新制线香、修改订单、撤下香粉、香炉详情四类弹窗各司其职,配合统一的条件渲染机制和全屏半透明遮罩,交互流畅且一致。撤粉操作的二次确认、订单金额的动态着色、销量阈值的高亮显示等细节,都体现了对用户体验的深入考量。

香道铺应用整体运行效果 - 本周线香销量柱状图与线香架列表


标签: HarmonyOS · ArkTS · 华为 · 非遗和香 · 移动端开发

Logo

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

更多推荐