摘要:本文以一个完整的中华传统筷子文化展示与交易管理应用"筷子坊"为案例,深入剖析鸿蒙HarmonyOS ArkTS声明式UI开发范式的核心要点。从以"乌木黑·朱漆红·鎏金黄·竹青绿·米纸底"五组语义色为核心的东方木作色彩体系的接口化定义,到响应式数据流@Observed/@ObjectLink在五Tab场景(筷子陈列/筷架库/礼盒坊/订单台账/客评口碑)下的深度运用,再到自定义模态弹窗体系、条件渲染路由、layoutWeight弹性布局权重驱动的数据可视化等复杂交互模式的完整实现,逐段拆解每一个关键代码块背后的设计思想与技术原理。全文覆盖接口约束、枚举安全、数据建模、状态驱动渲染、组件拆分策略、颜色透明度拼接、ForEach键值策略等20+个技术知识点,适合有一定ArkTS基础、希望深入理解鸿蒙声明式UI架构的开发者进阶阅读。


一、前言:一箸千年的数字化新生

筷子,古称"箸",是中国乃至整个东亚饮食文化中最具标志性的餐具之一。据考古发现,中国使用筷子的历史可追溯至三千多年前的商周时期——河南安阳殷墟出土的铜箸,是迄今发现的最早实物证据。"纣为象箸而箕子怖",《韩非子》中的这则典故,道出了筷子从实用器物到文化符号的漫长演变历程。

筷子不仅是进食工具,更承载着"天圆地方"的哲学隐喻:手持处方圆有度,夹取时刚柔相济;一双两根,暗合阴阳之道;首尾齐平,象征平等谦和。从材质上看,紫檀的沉稳、竹节的清雅、骨瓷的温润、大漆的光泽——每一双筷子都是匠人精神与自然之美的凝结。而在当代商业语境中,高端定制筷子已从日常用品升级为文化礼品、商务伴手礼甚至收藏级艺术品,形成了完整的产业链条。

在数字化浪潮席卷全球的今天,将这样一门承载着深厚文化底蕴的传统手工艺品以移动应用的形式呈现给现代用户,既是对非物质文化遗产传播方式的一次创新探索,也是对鸿蒙开发技术的一次深度实践。本文所剖析的"筷子坊"应用,正是围绕这一主题构建的一个集筷子陈列展示、筷架配件管理、礼盒产品目录、订单台账追踪与客户口碑评价于一体的综合性移动端展示平台。

在整个应用的开发过程中,我们不仅需要关注UI层面的视觉表现力——如何用代码还原木作工艺的温润质感与东方韵味,更需要深入理解ArkTS框架的底层机制:状态管理如何驱动UI刷新、组件间如何高效通信、数据流如何在父子组件间双向传递、自定义弹窗如何优雅地叠加在页面上方。这些问题的答案,都隐藏在接下来的每一行代码之中。本文将从架构总览开始,逐层深入,带你完成一次从"能写"到"写好"的鸿蒙开发进阶之旅。

二、项目架构总览:分层设计与模块化思想

本应用采用经典的单文件声明式架构,所有逻辑集中在一个源文件中,通过清晰的分层组织实现高内聚低耦合。整体架构可以划分为以下六个层次:

第一层——类型定义层(第1~132行):包含ChopstickPalette色彩接口、ChopstickTab枚举、TabMeta标签元数据接口以及全部业务实体接口(ChopstickItemRestItemBoxItemChopstickOrderItemChopstickReviewItem)和图表数据元接口(WeekChopstickMetaWoodShareMetaChopstickHotMetaChopstickTopMeta)。这一层是整个应用的"契约",所有后续的数据结构都必须严格遵循这些接口定义。

第二层——常量实例层(第23~167行):将抽象的类型定义具象化为可用的运行时对象,包括COLORS色彩常量(19色)、TAB_LIST标签列表(5项)、CHOPSTICK_COL装饰列阵以及四组静态图表数据(WEEK_SOLD周销量、WOOD_SHARE材质构成、CHOPSTICK_HOT制箸工艺热度、CHOPSTICK_TOP筷子销量排行)。

第三层——响应式数据类(第169~245行):@Observed装饰的ChopstickData类,封装了五组核心业务数据的动态数组(12款筷子、12种筷架、12款礼盒、12条订单、12条评价),是整个应用的状态中枢。

第四层——工具函数层(第247~302行):五个纯函数式的颜色映射器(getWoodColorgetRestColorgetPriceColorgetOrderColorgetReviewTagColor),根据业务规则返回语义化的颜色值。

第五层——主入口与容器组件(第304~521行):@Entry标记的ChopstickApp根组件,负责全局状态管理(10个@State变量)、页面骨架搭建、Tab路由分发(if-else if-else链)、底部导航栏渲染和四种弹窗的条件渲染。

第六层——子组件体系(第524行至文件末尾):包括1个标签组件(ChopstickTag)、5个内容组件(分别对应5个Tab页)和4个模态弹窗组件(新增筷子/修改订单/撤下筷架/礼盒详情),共同构成完整的功能矩阵。

这种分层设计的优势在于:当需要修改某个功能模块时,开发者可以快速定位到对应的层次进行修改,而不会对其他部分产生连锁反应。例如,如果需要调整配色方案,只需修改第一层的接口定义和第二层的常量实例;如果需要新增一个Tab页面,只需在第五层添加路由分支并在第六层新增对应的内容组件即可。每一层都像一块积木,独立成型又严丝合缝地拼装在一起,这正是优秀工程架构应有的样子。

三、色彩体系设计:接口约束与常量定义

3.1 调色板接口定义

interface ChopstickPalette {
  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;          // 纯白
  gold: string;           // 鎏金(装饰专用)
  silkWhite: string;      // 绢白(点缀专用)
}

这段代码看似简单,却蕴含着深刻的设计思想。首先,使用interface而不是type来定义调色板结构,是因为interface在ArkTS中具有更好的类型推断能力和扩展性。接口中定义了19个颜色字段,覆盖了以下几大语义类别:

  • 主色调系列(primary / primaryLight / primaryDark):以乌木黑色#2F2A26为核心,模拟高档筷材的本色。三个梯度分别用于常规主色(如标题文字、按钮填充)、次要强调(如图标胶囊背景)和深色背景区域(如顶部Header栏)。
  • 强调色系列(accent / accentLight):朱红色#B03A2E取自传统大漆的朱砂色泽,用于关键操作按钮和高亮元素(如柱状图中≥60的高峰柱);其浅变体珊瑚粉#D0887E用于次级强调。
  • 背景色系(bg / cardBg / cardAlt):米纸色#F4F0E8作为页面底色营造古籍翻阅感;纯白#FFFFFF作为卡片底色保证内容可读性;浅米#EDE6D8作为交替行背景增强列表视觉节奏。
  • 文本色三级灰度(textPrimary / textSecondary / textHint):从墨黑#2A231C到灰褐#75695B再到淡灰#A89A88,形成清晰的信息层级。
  • 功能语义色(success / warning / danger):竹青绿#8FAE7B表示成功/完成(如竹材筷子的标识色),鎏金黄#C98A4E表示提醒/待办(如星级评价的颜色),暗朱红#8E2F24表示危险/不可逆操作(如删除确认按钮)。
  • 主题专属色(gold / silkWhite):鎏金#D4A24E专用于装饰性元素(Header中的金色方块、进度条高亮点),绢白#F7F3E9用于浅色点缀(如筷架卡片的白色圆点)。

这种19色的精细划分,使得整个应用的每一个视觉元素都有明确的"色彩归属",避免了随意使用硬编码颜色值带来的视觉混乱。更重要的是,通过接口约束,任何遗漏字段或类型不匹配都会在编译阶段被TypeScript编译器捕获,实现了"编译期安全"。

3.2 色彩常量实例化

const COLORS: ChopstickPalette = {
  primary: '#2F2A26',
  primaryLight: '#6A5F55',
  primaryDark: '#1E1A17',
  accent: '#B03A2E',
  accentLight: '#D0887E',
  bg: '#F4F0E8',
  cardBg: '#FFFFFF',
  cardAlt: '#EDE6D8',
  textPrimary: '#2A231C',
  textSecondary: '#75695B',
  textHint: '#A89A88',
  border: '#DCD2BE',
  line: '#EAE2D2',
  success: '#8FAE7B',
  warning: '#C98A4E',
  danger: '#8E2F24',
  white: '#FFFFFF',
  gold: '#D4A24E',
  silkWhite: '#F7F3E9'
};

这里有一个非常值得注意的细节:COLORS常量的类型被显式标注为ChopstickPalette。这意味着TypeScript编译器会在编译阶段检查COLORS对象是否完整实现了接口定义的所有19个字段。如果遗漏了任何一个颜色字段,或者字段类型不匹配(比如不小心把数字赋给了string类型的字段),编译器会立即报错。这种"编译期安全"是大型项目中极其重要的保障,它能在代码运行之前就消灭掉大量潜在的类型错误。

此外,所有颜色值均采用标准的#RRGGBB六位十六进制格式,而非CSS变量名或语义别名。这种选择在ArkTS生态中是务实的——ArkTS的声明式UI直接支持字符串形式的颜色值,无需额外的运行时解析开销。同时,将所有颜色集中在一个常量对象中管理,使得后续的主题切换(如实现"暗色模式")只需要替换这个常量对象即可,体现了良好的架构前瞻性。

四、枚举与标签配置:类型安全的页面路由

4.1 Tab枚举定义

enum ChopstickTab {
  CHOPSTICK = 0,
  REST = 1,
  BOX = 2,
  ORDER = 3,
  REVIEW = 4
}

ChopstickTab枚举定义了应用底部导航栏的五个页面标识,从0到4依次对应:筷子陈列、筷架库、礼盒坊、订单台账、客户评价。使用枚举而非魔术数字(magic number)的好处是多方面的:

第一,类型安全。当我们在代码中编写this.curTab === ChopstickTab.CHOPSTICK时,编辑器会提供自动补全和拼写检查,有效避免将CHOPSTICK误写成CHOPSTIK之类的低级错误。

第二,语义明确。枚举成员名称本身就是最好的文档——任何阅读代码的人都能立刻理解ChopstickTab.REVIEW代表的是"客评"页面,而数字4则需要查阅上下文才能确定含义。

第三,重构友好。如果未来需要调整Tab的顺序(比如把"订单"移到第二个位置),只需修改枚举值的赋值顺序,所有引用该枚举的地方都会自动适配。

4.2 标签元数据配置

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

const TAB_LIST: TabMeta[] = [
  { key: 'chopstick', icon: '🥢', label: '筷子', color: '#2F2A26' },
  { key: 'rest', icon: '🥄', label: '筷架', color: '#B03A2E' },
  { key: 'box', icon: '🎁', label: '礼盒', color: '#D4A24E' },
  { key: 'order', icon: '📦', label: '订单', color: '#8FAE7B' },
  { key: 'review', icon: '⭐', label: '客评', color: '#C98A4E' }
];

这是一个非常优雅的设计模式:将标签的配置信息(唯一标识key、图标emoji、显示文字label、主题色color)抽象为一个数据数组,而非硬编码在组件的build()方法中。这种"数据驱动UI"的思路,使得后续新增或修改标签时,只需要修改TAB_LIST数组即可,无需触碰任何组件代码。

每个标签还拥有自己的主题色(color字段),这为选中态的视觉表现提供了丰富的个性化空间。例如,"筷子"Tab选中时使用乌木黑高亮,"筷架"Tab则使用朱漆红,"礼盒"Tab用鎏金黄,"订单"Tab用竹青绿,"客评"Tab用琥珀金——让用户在不同页面间切换时能获得一致的色彩反馈但又各具辨识度,形成一种"五色流转"的视觉韵律。

五、数据模型设计:接口驱动的类型安全

5.1 业务实体接口定义

interface ChopstickItem {
  name: string;      // 箸名
  wood: string;      // 材质(乌木/紫檀/花梨/楠竹...)
  craft: string;     // 工艺(髹漆/大漆/金绘/雕刻/整木...)
  price: number;     // 价格(元)
  emoji: string;     // 表情图标
}

interface RestItem {
  name: string;      // 筷架名
  material: string;  // 材质(青瓷/和田玉/琉璃/花梨...)
  level: number;     // 承托精度等级(0-100)
  place: string;     // 产地
  emoji: string;
}

interface BoxItem {
  name: string;      // 礼盒名
  style: string;     // 风格纹样(龙凤/福寿/如意/山水...)
  level: number;     // 品级(0-100)
  place: string;     // 产地
  emoji: string;
}

interface ChopstickOrderItem {
  name: string;      // 客户名称
  region: string;    // 所属区域
  count: number;     // 订购数量(双)
  amount: number;    // 订单金额(元)
  emoji: string;
}

interface ChopstickReviewItem {
  name: string;      // 评价人姓名
  score: number;     // 评分(1-5星)
  date: string;      // 评价日期
  content: string;   // 评价内容
  tag: string;       // 评价标签
  emoji: string;
}

这里展示了五种业务实体的接口定义。每个接口都精心设计了字段,紧密围绕筷子行业的实际业务场景:

  • ChopstickItem(筷子条目):包含箸名(如"黑檀箸"、"朱漆箸"、"金绘箸")、木材种类(乌木、紫檀、花梨、楠竹、湘妃竹、骨瓷等)、制作工艺(髹漆、大漆、金绘、雕刻、整木、旋削、烙花、烧制、螺钿)、价格和表情图标。其中wood字段不仅是显示信息,还是后续getWoodColor()颜色映射函数的核心输入参数——不同木材被赋予不同的主题色(乌木/黑檀→乌木黑,紫檀/酸枝/红木→朱漆红,花梨→鎏金黄,楠竹/湘妃竹→竹青绿)。
  • RestItem(筷架条目):记录筷架名称、材质(青瓷、和田玉、琉璃、花梨、楠竹、黄铜、粉彩、青石、骨瓷、足银、大漆、玛瑙等十二种材质)、承托精度等级(数值型91-96,用于进度条可视化)和产地(龙泉、和田、博山、海南、安吉、大理、景德镇、泰山、唐山、福州、阜新等全国名窑产地)。level字段是筷架页面的核心数据维度,直接影响每款筷架的"承托精度"进度条长度和颜色。
  • BoxItem(礼盒条目):包含礼盒名称(婚庆/寿宴/乔迁/商务/伴手/节庆/满月/谢师/年会/旅行/便携/收藏共12类场景)、风格纹样(龙凤/福寿/如意/山水/花鸟/祥云/百子/松鹤/鎏金/竹纹/素木/雕花)、品级和产地。礼盒品类覆盖了中国人际交往的主要礼品场景,体现了筷子文化的社交属性。
  • ChopstickOrderItem(订单条目):记录客户名称、所属区域(华北/华东/华中/华南/西南/海外六大区域)、订购数量和金额。amount字段是getOrderColor()的颜色映射依据——金额越高颜色越"危险"(暗朱红),形成直观的业务警示。
  • ChopstickReviewItem(评价条目):包含评分(4-5星为主)、日期、评价内容和标签。tag字段是getReviewTagColor()的映射输入,不同类型的评价标签被赋予不同的主题色(如"木质上乘"→乌木黑、"光泽喜人"→朱漆红、"走量飞快"→竹青绿)。

这五组接口构成了整个应用的数据模型基石。值得注意的是,所有接口中的emoji字段虽然不是严格的"业务数据",但在ArkTS的Text组件中可以直接渲染表情符号,为每个条目提供了轻量级的视觉标识,增强了列表的可读性和趣味性。

5.2 图表数据元接口

interface WeekChopstickMeta {
  day: string;   // 星期几
  value: number; // 销量
}

interface WoodShareMeta {
  name: string;   // 材质名称
  count: number;  // 数量
  color: string;  // 主题色
}

interface ChopstickHotMeta {
  name: string;   // 工艺名称
  heat: number;   // 热度值
  color: string;  // 主题色
}

interface ChopstickTopMeta {
  name: string;   // 箸名
  sold: number;   // 销量
  color: string;  // 主题色
}

这四个接口专门服务于应用中的四种图表/排行榜可视化组件。它们的设计遵循了一个共同的范式:名称 + 数值 + 主题色的三元组结构。这种统一的数据形状使得四种图表可以共享同一套渲染逻辑(ForEach + layoutWeight条形图),只是数据源不同而已。

WeekChopstickMeta用于垂直柱状图(周销量走势),WoodShareMeta/ChopstickHotMeta/ChopstickTopMeta用于水平条形图(材质占比/工艺热度/销量排行)。每种接口都自带color字段,使得每个数据条都可以拥有独立的颜色,而不是强制使用统一的系列色。

六、静态数据集:应用的基础数据源

6.1 图表静态数据

const WEEK_SOLD: WeekChopstickMeta[] = [
  { day: '周一', value: 24 },
  { day: '周二', value: 31 },
  { day: '周三', value: 39 },
  { day: '周四', value: 46 },
  { day: '周五', value: 58 },
  { day: '周六', value: 68 },
  { day: '周日', value: 61 }
];

这里展示了周销量数据。在实际项目中,这些数据通常来自后端API接口,但本应用作为展示型Demo采用了静态数据的方式。数据的设计颇具巧思——销量从周一到周六逐步递增(24→31→39→46→58→68),周日略有回落(61),这完全符合零售业"工作日平稳、周末高峰、周日略降"的实际规律,让数据看起来真实可信。周六峰值68双的数据点尤其值得关注,它在UI中被用作阈值判断的分界线(value ≥ 60 使用accent朱漆红,否则使用gold鎏金黄)。

const WOOD_SHARE: WoodShareMeta[] = [
  { name: '黑檀', count: 5, color: '#2F2A26' },
  { name: '紫檀', count: 4, color: '#B03A2E' },
  { name: '竹', count: 3, color: '#8FAE7B' },
  { name: '花梨', count: 2, color: '#D4A24E' }
];

材质构成数据反映了当前产品矩阵中四大主流木材的市场占比。黑檀(5款)居首,这是高端筷子市场最受欢迎的材质——色泽深沉、纹理细腻、手感温润;紫檀(4款)次之,皇家御用的尊贵气质使其成为商务礼品的首选;竹材(3款)代表环保轻便路线,深受年轻消费者喜爱;花梨(2款)最少但单价适中,性价比突出。四者的count之和为14(略多于实际12款筷子数,因为部分款式可能复用同材质),在水平条形图中以总轨道20为基准进行比例分配。

6.2 工艺热度与排行榜数据

const CHOPSTICK_HOT: ChopstickHotMeta[] = [
  { name: '整木旋削', heat: 97, color: '#2F2A26' },
  { name: '大漆髹涂', heat: 95, color: '#B03A2E' },
  { name: '金银绘纹', heat: 94, color: '#D4A24E' },
  { name: '竹节雕刻', heat: 92, color: '#8FAE7B' },
  { name: '镶嵌螺钿', heat: 91, color: '#6A5F55' },
  { name: '火绘烙花', heat: 90, color: '#C98A4E' }
];

const CHOPSTICK_TOP: ChopstickTopMeta[] = [
  { name: '黑檀箸', sold: 97, color: '#2F2A26' },
  { name: '朱漆箸', sold: 93, color: '#B03A2E' },
  { name: '金绘箸', sold: 90, color: '#D4A24E' },
  { name: '竹节箸', sold: 87, color: '#8FAE7B' },
  { name: '紫檀箸', sold: 84, color: '#6A5F55' },
  { name: '花梨箸', sold: 80, color: '#C98A4E' }
];

制箸工艺热度数据涵盖了传统手工制筷的六大核心工序,从"整木旋削"(97分)到"火绘烙花"(90分),热度值递减但差距不大(仅7分跨度),说明每一道工序在制箸匠心中都至关重要。整木旋削居首,体现了"一木成箸"的极致追求——从一根完整原木上旋削出两根筷子,纹理对称、质地均匀,是最考验匠人功底的基础工序。

筷子销量TOP6则列出了最受欢迎的六款单品,"黑檀箸"以97的销量遥遥领先——这款产品精准切中了高端餐饮市场对餐具品质的需求痛点。值得注意的是,TOP6的排名与WOOD_SHARE的材质分布高度吻合:黑檀材质的产品占据了榜首位置,印证了"材质决定销量"的商业逻辑。

七、响应式数据类:@Observed装饰器的深度运用

@Observed
export class ChopstickData {
  chopsticks: ChopstickItem[] = [
    { name: '黑檀箸', wood: '乌木', craft: '髹漆', price: 388, emoji: '🥢' },
    { name: '朱漆箸', wood: '红檀', craft: '大漆', price: 288, emoji: '🔴' },
    { name: '金绘箸', wood: '黑檀', craft: '金绘', price: 468, emoji: '✨' },
    { name: '竹节箸', wood: '楠竹', craft: '雕刻', price: 168, emoji: '🎋' },
    { name: '紫檀箸', wood: '紫檀', craft: '整木', price: 588, emoji: '🟣' },
    { name: '花梨箸', wood: '花梨', craft: '整木', price: 328, emoji: '🟠' },
    { name: '酸枝箸', wood: '酸枝', craft: '髹漆', price: 428, emoji: '🟤' },
    { name: '乌木箸', wood: '乌木', craft: '整木', price: 518, emoji: '⚫' },
    { name: '红木箸', wood: '红木', craft: '旋削', price: 258, emoji: '🟥' },
    { name: '湘妃竹箸', wood: '湘妃竹', craft: '烙花', price: 348, emoji: '🦌' },
    { name: '骨瓷箸', wood: '骨瓷', craft: '烧制', price: 198, emoji: '⚪' },
    { name: '木胎漆箸', wood: '黑檀', craft: '螺钿', price: 668, emoji: '🖌️' }
  ];

  rests: RestItem[] = [
    { name: '陶瓷箸枕', material: '青瓷', level: 96, place: '龙泉', emoji: '🏺' },
    // ... 共12种筷架
  ];

  boxes: BoxItem[] = [
    { name: '婚庆礼盒', style: '龙凤', level: 96, place: '苏州', emoji: '💒' },
    // ... 共12款礼盒
  ];

  orders: ChopstickOrderItem[] = [
    { name: '国宴餐厅', region: '华北', count: 1200, amount: 720000, emoji: '🏛️' },
    // ... 共12条订单
  ];

  reviews: ChopstickReviewItem[] = [
    { name: '国宴主厨', score: 5, date: '09-03',
      content: '黑檀箸握感沉稳,夹菜即知好木好工。', tag: '木质上乘', emoji: '🏛️' },
    // ... 共12条评价
  ];
}

这是整个应用最核心的数据层代码@Observed装饰器是ArkTS框架提供的一个关键装饰器,它的作用是将一个普通的类标记为"可观察的"。当一个类被@Observed标记后,该类实例的属性变化会被ArkTS框架自动追踪,任何引用了该实例属性的@ObjectLink@State变量都会在属性变化时自动触发UI重新渲染。

ChopstickData类封装了五组核心业务数据:

  • chopsticks(12款筷子):涵盖从"黑檀箸"(¥388,乌木髹漆)到"木胎漆箸"(¥668,黑檀螺钿)的全价位段产品矩阵。价格区间¥168-¥668,覆盖入门级(竹节箸、骨瓷箸)到收藏级(木胎漆箸、紫檀箸)的全部消费层级。木材种类涵盖乌木、红檀、黑檀、楠竹、紫檀、花梨、酸枝、红木、湘妃竹、骨瓷十大材质,工艺包括髹漆、大漆、金绘、雕刻、整木、旋削、烙花、烧制、螺钿九大技法。
  • rests(12种筷架):材质涵盖陶瓷(龙泉青瓷、景德镇粉彩)、玉石(和田玉、玛瑙)、琉璃(博山琉璃)、木材(海南花梨、安吉楠竹)、金属(大理黄铜、足银)、漆器(福州大漆)以及石材(泰山青石)和骨质(唐山骨瓷)八大门类。产地遍布全国名窑名城,精度等级91-96不等。
  • boxes(12款礼盒):场景覆盖婚庆(龙凤纹苏州产)、寿宴(福寿纹杭州产)、乔迁(如意纹南京产)、商务(山水纹上海产)、伴手(花鸟纹扬州产)、节庆(祥云纹北京产)、满月(百子纹无锡产)、谢师(松鹤纹绍兴产)、年会(鎏金纹深圳产)、旅行(竹纹安吉产)、便携(素木宁波产)、收藏(雕花纹东阳产)——几乎囊括了中国人的全部重要社交时刻。
  • orders(12条订单):客户涵盖国宴餐厅、日料连锁、高端酒店、文创品牌、海外中餐、直播电商、婚庆公司、航空配餐、连锁面馆、伴手礼店、企业定制、米其林餐厅等多元渠道,区域分布华东/华北/华南/华中/西南/海外六大板块,单笔金额35万-72万不等。
  • reviews(12条评价):全部4-5星好评(平均约4.92星),评价者身份包括国宴主厨、日料店长、酒店采购、文创买手、海外中餐负责人、直播选品、婚庆策划、配餐经理、面馆老板、伴手礼主理、企业行政、老主顾等真实用户画像,标签涵盖"木质上乘"、"光泽喜人"、"体面大方"、"纹样雅致"、"稳定耐热"、"走量飞快"、"红金喜气"、"轻便环保"、"经久耐用"、"包装精美"、"定制贴心"、"一箸情深"等多维度评价关键词。

这种将所有业务数据集中在一个@Observed类中的设计,带来了一个重要的架构优势:单一数据源原则(Single Source of Truth)。无论哪个子组件需要访问筷子列表、筷架数据还是订单信息,它们都通过@ObjectLink引用同一个ChopstickData实例。当任何一个数据发生变化(比如新增了一款筷子、删除了一款筷架),所有引用该数据的组件都会自动收到通知并刷新UI,无需手动编写繁琐的事件监听和数据同步逻辑。

八、颜色工具函数:纯函数的设计哲学

function getWoodColor(wood: string): string {
  if (wood === '乌木' || wood === '黑檀') return '#2F2A26';
  else if (wood === '紫檀' || wood === '酸枝' || wood === '红木' || wood === '红檀') return '#B03A2E';
  else if (wood === '花梨') return '#D4A24E';
  else if (wood === '楠竹' || wood === '湘妃竹') return '#8FAE7B';
  return '#6A5F55';
}

function getRestColor(level: number): string {
  if (level >= 95) return '#D4A24E';
  else if (level >= 93) return '#B03A2E';
  return '#8FAE7B';
}

function getPriceColor(price: number): string {
  if (price >= 500) return '#B03A2E';
  else if (price >= 300) return '#D4A24E';
  else if (price >= 200) return '#8FAE7B';
  return '#6A5F55';
}

function getOrderColor(amount: number): string {
  if (amount >= 600000) return '#B03A2E';
  else if (amount >= 450000) return '#D4A24E';
  else if (amount >= 350000) return '#8FAE7B';
  return '#6A5F55';
}

function getReviewTagColor(tag: string): string {
  if (tag === '木质上乘' || tag === '稳定耐热' || tag === '经久耐用' || tag === '一箸情深')
    return '#2F2A26';
  else if (tag === '光泽喜人' || tag === '红金喜气' || tag === '纹样雅致')
    return '#B03A2E';
  else if (tag === '体面大方' || tag === '走量飞快' || tag === '轻便环保' || tag === '包装精美')
    return '#8FAE7B';
  else if (tag === '定制贴心') return '#D4A24E';
  return '#C98A4E';
}

这五个函数构成了应用的颜色映射层,它们都是纯函数(Pure Function)——相同的输入永远产生相同的输出,没有任何副作用。这种函数式的设计哲学在ArkTS开发中具有多重价值:

第一,可测试性极强。每个函数都可以独立编写单元测试,传入已知输入、断言输出颜色,无需mock任何组件或状态。

第二,逻辑集中且可复用。颜色映射规则分散在多个组件中是维护噩梦——如果要调整"高价筷"的阈值从500改为550,需要在每个使用处逐一修改。而现在只需改getPriceColor一处。

第三,语义化表达业务规则。函数名本身就是最好的文档——getWoodColor(wood)一眼就能看出是根据木材种类返回筷子的主题色;getReviewTagColor(tag)则是根据评价标签返回标签颜色。

特别值得分析的是各函数的阶梯式阈值设计

  • getPriceColor:价格≥500返回朱漆红(danger色系,暗示"高端珍品"),300-499返回鎏金黄,200-299返回竹青绿(success色,暗示"性价比之选"),<200返回灰褐。这种将业务语义(价格区间)映射到视觉语义(颜色情感)的模式,是数据可视化中的经典手法。
  • getOrderColor:金额≥60万返回朱漆红(暗示"大额订单需关注"),45-59.9万返回鎏金黄,35-44.9万返回竹青绿,<35万返回灰褐。阈值的设定参考了实际订单数据的分布特征(最大72万、最小35万)。
  • getReviewTagColor:按评价的情感倾向分组——正面品质描述(木质上乘/稳定耐热/经久耐用/一箸情深)归入乌木黑系,视觉感受描述(光泽喜人/红金喜气/纹样雅致)归入朱漆红系,功能性评价(体面大方/走量飞快/轻便环保/包装精美)归入竹青绿系,服务体验(定制贴心)单独归入鎏金黄系。

九、主入口组件:应用骨架与路由管理

9.1 状态声明

@Entry
@Component
struct ChopstickApp {
  @State curTab: number = 0;
  @State data: ChopstickData = new ChopstickData();
  @State showAddChopstick: boolean = false;
  @State showEditOrder: boolean = false;
  @State showDeleteRest: boolean = false;
  @State showDetailBox: boolean = false;
  @State delRestName: string = '';
  @State detailBoxName: string = '';
  @State chopstickSpin: boolean = false;
  @State lanternFloat: boolean = false;

ChopstickApp是整个应用的根组件,由@Entry装饰器标记为应用入口,由@Component标记为可复用组件。它声明了10个@State状态变量,可以分为三类:

路由与数据状态(2个):

  • curTab:当前选中的Tab索引(0-4),控制底部导航栏的高亮和内容区的切换。
  • dataChopstickData实例,作为全局唯一的数据源传递给所有子组件。

弹窗显隐状态(4个布尔值 + 2个字符串):

  • showAddChopstick / showEditOrder / showDeleteRest / showDetailBox:分别控制四种弹窗的显示/隐藏。
  • delRestName / detailBoxName:传递给删除确认弹窗和详情弹窗的上下文参数(要撤下的筷架名、要查看详情的礼盒名)。

动画交互状态(2个布尔值):

  • chopstickSpin:控制头部筷子图标的旋转动画(点击切换90°旋转)。
  • lanternFloat:控制灯笼图标的浮动动画(点击切换Y轴偏移-10px ↔ +4px)。

这种细粒度的状态拆分,使得每个UI交互都有独立的状态变量驱动,互不干扰。当需要添加新的弹窗或交互效果时,只需新增一个@State变量即可,不会影响现有逻辑。

9.2 构建方法与页面结构

build()方法是ArkTS声明式UI的核心——它描述了"UI应该长什么样",框架负责在状态变化时高效地diff和更新真实DOM。ChopstickAppbuild()方法构建了以下三层结构:

第一层:主内容区(外层Column)。包含两个子区域:

  • 顶部Header区域:深棕近黑色(primaryDark = #1E1A17)背景,营造沉稳典雅的东方氛围。包含应用标题"🥢 筷子坊"、英文副标题"Chopsticks · 一箸千年 五味调和"、右侧操作图标区(筷子旋转图标、灯笼浮动图标、礼盒图标,三者包裹在一个半透明的圆角胶囊容器中),以及下方的12个装饰性点缀点(用CHOPSTICK_COL数组遍历生成,r%3==0时绘制金色矩形模拟金箔饰片,r%3==1时绘制朱漆红圆形模拟漆珠,r%3==2时绘制竹青绿圆形模拟玉饰),最后是一条分割线+标语"🏮 一箸千味 · 三餐四季 🥢"。整个Header区域的视觉设计精妙地融合了中国传统装饰元素与现代移动端UI规范。
  • 内容切换区:根据curTab的值,使用if-else if-else链条件渲染对应的内容组件(ChopstickContent / RestContent / BoxContent / ChopstickOrderContent / ChopstickReviewContent)。每个内容组件都接收this.data作为@ObjectLink参数,并绑定各自的回调函数(onAdd/onDel/onDetail/onEdit)。

第二层:底部导航栏(固定在底部)。顶部有一条3px高的accent朱漆红线作为视觉分隔,下方使用ForEach遍历TAB_LIST数组渲染5个Tab项。每个Tab项根据是否为当前选中项来决定:文字颜色(主题色 vs 提示色textHint)、字重(Bold vs Normal)、背景色(半透明主题色color+'14' vs 完全透明'#00000000')、边框(1px主题色描边 vs 无边框)。点击时更新curTab状态触发切换。

第三层:弹窗叠加层。四个if条件分别控制四种弹窗的渲染,弹窗以绝对定位的方式覆盖在主内容区上方。每个弹窗都接收必要的props(如DeleteRestModal接收title属性显示要删除的筷架名,DetailBoxModal接收name属性显示要查看的礼盒名)和一个onClose回调函数。

9.3 弹窗的条件渲染

if (this.showAddChopstick) {
  AddChopstickModal({ onClose: () => { this.showAddChopstick = false; } })
}
if (this.showEditOrder) {
  EditChopstickOrderModal({ onClose: () => { this.showEditOrder = false; } })
}
if (this.showDeleteRest) {
  DeleteRestModal({ title: this.delRestName, onClose: () => { this.showDeleteRest = false; } })
}
if (this.showDetailBox) {
  DetailBoxModal({ name: this.detailBoxName, onClose: () => { this.showDetailBox = false; } })
}

弹窗的渲染采用了条件渲染 + 回调关闭的模式。每个弹窗组件接收一个onClose回调函数,当用户点击弹窗内的"取消"/"确认"/"知道了"/"再想想"等按钮时,调用onClose()将对应的showXxx状态设为false,从而触发弹窗从DOM中移除。

DeleteRestModalDetailBoxModal额外接收了title/name属性,用于在弹窗内显示具体的操作对象(要撤下哪款筷架、查看哪个礼盒的详情)。这种"状态提升"(Lifting State Up)的模式——将弹窗所需的上下文数据存储在父组件的状态中、通过props传递给弹窗——是React/ArkTS中处理跨组件数据流的经典方案。

此外,根组件中还定义了一个modalOverlay Builder方法,虽然在本版本中未被直接调用,但它预留了一个通用的遮罩点击关闭能力(通过一个1x1像素的透明Button捕获点击事件),体现了开发者对未来功能扩展的前瞻性设计。

十、内容组件体系:@ObjectLink与数据双向绑定

10.1 标签组件 ChopstickTag

@Component
struct ChopstickTag {
  @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')  // 8% 透明度
      .borderRadius(10)
      .border({ width: 1, color: this.color + '40' })  // 25% 透明度边框
  }
}

ChopstickTag是一个极简但精心设计的标签组件,仅接收两个@Prop属性:text(标签文字)和color(主题色)。它的设计精髓在于颜色透明度的十六进制拼接技巧

  • 背景色使用this.color + '14',即在原色hex后面追加14(即8%不透明度的alpha通道),生成类似#2F2A2614的8位颜色值。ArkTS原生支持这种8位hex颜色格式,前6位是RGB,后2位是Alpha(00=完全透明,FF=完全不透明)。
  • 边框色使用this.color + '40'(25%不透明度),比背景更明显但不抢眼。

这种"同色系多层透明度叠加"的技法,使得标签在任何背景下都能保持和谐的视觉效果——背景色淡淡的(8%)提供区域感,边框稍深(25%)勾勒轮廓,文字用原色(100%)保证可读性。三者来自同一个color变量,天然协调,永远不会出现"背景是蓝色但文字是红色"的色彩冲突。

10.2 筷子内容组件 ChopstickContent

@Component
struct ChopstickContent {
  @ObjectLink data: ChopstickData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        // 销量图表卡片
        Column() { /* ... */ }
        // 筷子列表
        ForEach(this.data.chopsticks, (p: ChopstickItem, i: number) => {
          Row() { /* 卡片内容 */ }
        }, (p: ChopstickItem, i: number) => 'cp' + p.name + i)
      }
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

ChopstickContent是筷子Tab页的主体内容组件。它使用了@ObjectLink装饰器来接收父组件传递的ChopstickData实例。@ObjectLink@Prop的关键区别在于:@ObjectLink建立的是引用关系,而非值的拷贝——当ChopstickData实例的属性(如chopsticks数组)发生变化时,ChopstickContent中引用这些属性的部分会自动刷新。这使得数据在父子组件间实现了"双向绑定"的效果。

组件内部由两大部分组成:

第一部分:本周销量图表卡片。顶部有一排装饰性的几何线条组合(金色短横线+朱漆红长虚线+竹青绿圆点+朱漆红短横线的交替排列,模仿传统中式建筑的斗拱/额枋装饰语言),下方是标题"📈 本周筷子销量"和副标题"周六68双"。柱状图使用ForEach遍历WEEK_SOLD数据,每根柱子的高度value决定(直接取value数值作为像素高度),颜色由阈值判断决定(≥60用accent朱漆红,否则用gold鎏金黄)。柱子使用borderRadius(2)实现微圆角,底部对齐(alignItems(VerticalAlign.Bottom))模拟真实柱状图的基线对齐效果。整个卡片使用白色背景+朱漆红半透明边框(accent+'55'),圆角14px,营造出精致的卡片质感。

第二部分:筷子陈列列表。使用ForEach遍历this.data.chopsticks数组,每张卡片是一个Row横向布局,包含三个区域:

  • 左侧图标区(宽60):外层是一个带极淡木色背景(woodColor+'0D'=5%透明度)和淡边框(33%透明度)的圆角容器,内层是一个更深的圆角徽记区(8%透明度背景+40%透明度边框),中心显示筷子emoji。徽记区下方有一排小装饰:金色短横线+木色圆点,模拟筷子顶部的金属箍或镶嵌装饰。整体设计层次丰富,视觉上有"套娃"般的纵深效果。
  • 中间信息区layoutWeight(1)占据剩余空间):第一行是箸名(粗体13号墨黑)+ 价格(粗体10号,颜色由getPriceColor(price)决定);第二行是两个ChopstickTag标签(木材名+工艺名)+ "一箸千年"提示文字(淡灰色9号)。标签的颜色分别由getWoodColor(p.wood)和固定success竹青绿决定。
  • 卡片背景色使用i % 2 === 0实现斑马纹交替(白色cardBg/浅米cardAlt),增强列表的可读性和节奏感。每张卡片之间间隔8px,整体视觉效果整洁有序。

10.3 其他内容组件

RestContent(筷架库):结构与ChopstickContent类似,但顶部图表替换为"筷子销量TOP6"水平条形图(使用CHOPSTICK_TOP数据和layoutWeight实现比例条——彩色条的layoutWeight(t.sold)与灰色占位的layoutWeight(100-t.sold)共同构成100的总权重),列表项改为筷架卡片——左侧显示emoji+准度圆点+绢白圆点装饰,中间显示筷架名+材质,下方显示"承托精度"进度条(同样用layoutWeight(c.level)实现比例填充,总权重100)。右侧有删除图标(🗑️),点击触发onDel回调并将筷架名传回父组件以弹出删除确认框。

BoxContent(礼盒坊):顶部图表为"制箸工艺热度"水平条形图(CHOPSTICK_HOT数据,同样使用layoutWeight(d.heat)/layoutWeight(100-d.heat)的比例模式),列表项为礼盒卡片——显示礼盒emoji+品级圆点装饰,中间显示礼盒名+品级数字,下方显示风格纹样标签(ChopstickTag)+产地文字。右侧有详情图标(👁️),点击触发onDetail回调。

ChopstickOrderContent(订单台账):顶部图表为"材质构成"水平条形图(WOOD_SHARE数据,使用layoutWeight(s.count)/layoutWeight(20-s.count),总权重20),列表项为订单卡片——显示客户emoji+名称+区域+数量(格式化为"region · count双"),右侧显示金额(颜色由getOrderColor(o.amount)决定)和编辑图标(✏️)。

ChopstickReviewContent(客户评价):无顶部图表,直接是评价列表。每条评价显示用户emoji+姓名+日期(竖向排列在左侧)、星级(用.repeat(score)动态重复渲染,颜色取warning鎏金黄)+ 标签(ChopstickTag组件,颜色由getReviewTagColor(r.tag)决定)(横向排列在右上),下方是评价正文(11号灰褐色,行高16px保证舒适阅读间距)。整体采用Column纵向布局而非Row横向布局,因为评价内容的文字较长需要换行显示。

十一、弹窗组件体系:自定义模态对话框

应用包含四个自定义弹窗组件,它们共享一致的结构模式但各有不同的内容和用途:

11.1 新增筷子弹窗 AddChopstickModal

弹窗采用全屏遮罩 + 居中卡片的经典模态布局:

  • 外层Column:width('100%') height('100%'),背景色#66000000(40%黑色半透明遮罩),justifyContent(FlexAlign.Center)使内容居中。这个遮罩层有效阻断了用户对背景内容的交互,强制用户关注弹窗内容。
  • 内层卡片:宽度86%、最大高度78%、圆角16px、白色背景、16px内边距。86%的宽度选择既保证了在窄屏手机上的可用性,又在宽屏设备上保持了适当的留白美感。
  • 标题栏:"🥢 定制筷子" + 右侧"✕"关闭按钮,两端对齐(justifyContent(FlexAlign.SpaceBetween))。
  • 内容区:三行信息展示(品名"黑檀箸"、材质"乌木 · 髹漆 · 金绘"、预算"¥388"),每行使用cardAlt浅米背景色+10px圆角的统一样式,左侧56px宽的标签列(品名/材质/预算)使用灰褐色文字,右侧使用对应颜色的正文(预算金额使用primary乌木黑突出显示)。
  • 操作栏:左"取消"(描边按钮,border色取自border常量)+ 右"确认制箸"(实心主色按钮,background取自primary),右对齐。两个按钮均使用12px圆角(borderRadius(12)),与卡片的16px圆角形成层次差异。

11.2 其他弹窗组件

  • EditChopstickOrderModal(修改订单):结构与AddChopstickModal完全对称,内容改为三行信息(客户"国宴餐厅"、数量"1200 → 1300 双"、金额"¥780000"),金额使用accent朱漆红突出显示(暗示金额变动),确认按钮文案为"保存修改"。数量的箭头符号"→"直观表达了"修改前→修改后"的含义。
  • DeleteRestModal(删除确认):接收title@Prop属性显示要撤下的筷架名,内容为确认提示文字("确定撤下「xxx」吗?" + "撤下的筷架将从筷架库移除。"),两级文案形成"主要问题+补充说明"的信息层次。确认按钮使用危险色danger = 暗朱红#8E2F24)以警示操作的不可逆性,取消按钮文案改为"再想想"增加人文温度。
  • DetailBoxModal(礼盒详情):接收name@Prop属性,内容包含大号emoji+礼盒名展示区(cardAlt浅米背景+12px圆角)、一段富有文化气息的介绍文字("精选整木旋削成箸,髹大漆数道,绘金线于箸身,装入樟木礼盒,铺以锦缎衬里。一盒双箸,寓意成双成对,为席间添一份礼数。")、产地信息("苏州 · 手工坊")和匠人评级("甲级 · 制箸卅载"),单按钮"知道了"关闭。这段介绍文字巧妙地将制箸工艺流程融入其中,让用户在查看详情的同时感受到产品的文化价值。

四个弹窗的一致性设计(相同的外层遮罩透明度、卡片尺寸86%/最大高度78%、圆角16px、内边距16px、标题栏两端对齐布局、操作栏右对齐)确保了用户在使用不同功能时获得统一的交互体验。差异仅在内容区和按钮文案/颜色上体现各自的功能特性——这种"统一模板+差异化内容"的设计模式是大型应用中保证交互一致性的最佳实践。

十二、关键技术点深度解析

12.1 layoutWeight布局权重的妙用

layoutWeight是ArkTS弹性布局中最强大的属性之一。在本应用中被广泛用于两类场景:

水平条形图:在CHOPSTICK_TOPCHOPSTICK_HOTWOOD_SHARE的渲染中,每行的彩色条使用layoutWeight(dataValue)设置宽度权重,后面的灰色占位条使用layoutWeight(maxValue - dataValue)补足剩余空间。两者之和等于最大值(100或20),从而精确实现按比例分配宽度的效果——无需手动计算像素值,框架自动处理。例如在CHOPSTICK_TOP中,"黑檀箸"sold=97,则彩色条layoutWeight(97),灰色条layoutWeight(3),两者恰好填满整行宽度。

列表卡片内部布局:筷子卡片、筷架卡片等信息区的中间列使用layoutWeight(1)占据所有剩余空间,左右固定宽度的图标区(60px/56px)和操作区(emoji图标约20px)保持不变。这种"两头固定、中间弹性"的布局模式在移动端UI中极为常见,完美适配各种屏幕宽度。

12.2 颜色透明度的十六进制拼接

本应用大量使用了hex颜色 + 两位alpha的8位颜色格式,例如:

  • COLORS.primaryLight + '2E' → #6A5F552E(约18%不透明度,用于Header右侧图标胶囊背景)
  • COLORS.primaryLight + '80' → #6A5F5580(50%不透明度,用于图标胶囊边框)
  • COLORS.accent + '55' → #B03A2E55(约33%不透明度,用于卡片边框)
  • t.color + '14' → 如#2F2A2614(约8%不透明度,用于标签/Tab选中背景)
  • t.color + '40' → 如#2F2A2640(25%不透明度,用于标签/Tab选中边框)
  • getWoodColor(p.wood) + '0D' → 约5%不透明度,用于卡片图标区外层背景
  • getWoodColor(p.wood) + '33' → 20%不透明度,用于卡片图标区边框

这种技法的优势在于:不需要预先定义几十个半透明色变量,而是根据运行时的主题色动态生成任意透明度的衍生色。只要知道基础色和目标透明度,字符串拼接一行搞定。需要注意的是,alpha值的范围是00(全透明)到FF(完全不透明),常用参考值:0D≈5%,14≈8%,2E≈18%,33≈20%,40≈25%,55≈33%,66≈40%,80≈50%。

12.3 ForEach的键值生成策略

应用中所有的ForEach循环都精心设计了键值生成函数(key generator)

// 筷子列表
(p: ChopstickItem, i: number) => 'cp' + p.name + i
// 筷架列表
(c: RestItem, i: number) => 'rs' + c.name + i
// 礼盒列表
(b: BoxItem, i: number) => 'bx' + b.name + i
// 订单列表
(o: ChopstickOrderItem, i: number) => 'od' + o.name + i
// 评价列表
(r: ChopstickReviewItem, i: number) => 'rv' + r.name + i
// Tab栏
(t: TabMeta) => t.key
// CHOPSTICK_COL装饰点
(r: number) => 'chopstick' + r
// 图表数据
(w: WeekChopstickMeta) => w.day
(t: ChopstickTopMeta) => t.name
(d: ChopstickHotMeta) => d.name
(s: WoodShareMeta) => s.name

每个键值都采用了**"前缀 + 唯一标识 + 索引"**的三段式命名:

  • 前缀(cp/rs/bx/od/rv)区分不同的ForEach作用域,避免跨列表键值冲突。例如筷子列表中的"黑檀箸"和订单列表中的"黑檀箸"虽然name相同,但它们的键值分别是'cp黑檀箸0''od黑檀箸0',不会产生冲突。
  • 唯一标识(p.name/c.name等)保证同一条数据始终映射到同一个键。
  • 索引(i)作为兜底,即使两条数据name相同(理论上不应发生但有防御价值),索引也能保证唯一性。

正确的键值策略对于ArkTS的ForEach性能至关重要——框架通过键值来判断哪些节点是"新增"、哪些是"移动"、哪些是"删除",从而执行最小量的DOM操作。错误的键值(如使用Math.random())会导致每次渲染都销毁重建所有节点,造成严重的性能问题。

12.4 @Observed/@ObjectLink响应式机制

本应用的响应式数据流遵循以下路径:

ChopstickData (@Observed 类)
    ↓ @ObjectLink 引用传递
ChopstickContent / RestContent / BoxContent / ChopstickOrderContent / ChopstickReviewContent
    ↓ 组件内部读取属性
ForEach / Text / 等UI节点

ChopstickData的某个数组属性发生变化(如chopsticks.push(newItem)),ArkTS框架的响应式系统会:

  1. 检测到@Observed类实例的属性变更。
  2. 通知所有持有该实例@ObjectLink引用的组件。
  3. 这些组件重新执行build()方法。
  4. 框架diff新旧虚拟DOM树,只更新实际变化的部分。

这种机制比传统的"手动调用setState/refresh"模式更加声明式和自动化,开发者只需描述"数据是什么样子",框架负责"如何高效地更新UI"。值得一提的是,@ObjectLink要求传入的必须是@Observed类的实例——如果误传普通对象,编译器会在编译阶段报错,这也是ArkTS类型系统带来的安全保障。

12.5 条件渲染与状态驱动UI

应用的Tab切换和弹窗显示/隐藏完全由状态变量驱动,没有使用任何命令式的DOM操作(如appendChild/removeChild):

  • Tab切换:if (this.curTab === ChopstickTab.CHOPSTICK) 渲染ChopstickContent,否则依次判断其他Tab。curTab的变化由底部Tab栏的onClick事件触发——this.curTab = TAB_LIST.indexOf(t)。由于indexOf是线性查找且TAB_LIST仅有5项,性能完全不成问题。
  • 弹窗显示:if (this.showAddChopstick) 渲染AddChopstickModalshowAddChopstick由"➕制箸"按钮的onClick设为true,由弹窗内部的"取消"/"确认制箸"按钮的onClick通过onClose回调设为false
  • 动画交互:chopstickSpin控制筷子图标的.rotate()旋转(0°↔90°),lanternFloat控制灯笼图标的.translate()位移(y: -10 ↔ y: +4)。两个动画都使用Curve.EaseOut缓动曲线和约650ms的持续时间,确保动画流畅自然。

这种"状态即UI"(State is UI)的范式是现代前端框架的核心思想——UI是状态的纯函数,给定相同的状态必然产生相同的UI。这使得应用的行为完全可预测、可调试、可测试。

12.6 Header装饰性元素的匠心设计

值得单独一提的是Header区域中的两处装饰性设计:

CHOPSTICK_COL装饰点阵列:使用[0,1,2,...,11]共12个数字遍历生成一行装饰元素,通过r % 3的余数决定每个位置的图形形态——余0为金色短横线(模拟金箔嵌片),余1为朱漆红圆点(模拟漆珠),余2为竹青绿圆点(模拟玉饰)。这种基于数学规则的程序化生成方式,既保证了视觉上的规律美(三种图形循环往复),又避免了手写12个重复的UI节点。12这个数字也暗合"一打"的概念和中国传统文化中对偶成双的审美偏好。

分隔线+标语:使用layoutWeight(1)的两段分割线夹住中间的文字"🏮 一箸千味 · 三餐四季 🥢",分割线颜色使用primaryLight+'66'(40%透明度的浅棕),与深色Header背景形成微妙对比。标语本身简洁有力——"一箸千味"呼应筷子的多功能属性(进食/礼仪/馈赠/收藏),"三餐四季"点明筷子在日常生活中的陪伴角色。

十三、技术要点对比表

技术维度 本应用实现方案 设计意图与优势
状态管理 @State(10个) + @Observed类 + @ObjectLink引用 单一数据源 + 自动响应式更新,避免手动同步
组件通信 Props单向数据流 + 回调函数(onClose/onAdd/onDel/onDetail/onEdit) 父→子通过Props,子→父通过回调,清晰的单向数据流
路由方案 if-else if-else条件渲染 + 枚举索引 适合Tab数量固定(5个)的场景,简单直接无额外依赖
弹窗模式 条件渲染 + 全屏遮罩(#66000000) + 居中卡片(86%宽) + 回调关闭 统一的模态交互规范,遮罩阻止背景交互
列表渲染 ForEach + 三段式键值('prefix'+name+index) 高效diff、防冲突、可预测的节点复用
布局策略 layoutWeight弹性权重 + 固定像素结合 两头固定中间弹性的经典移动端布局
颜色系统 19色接口约束 + hex+alpha透明度拼接 编译期类型安全 + 运行时动态透明度
数据建模 5个业务接口 + 4个图表接口 + 5个纯函数颜色映射器 类型安全 + 逻辑集中 + 可独立测试
动画交互 .rotate() + .translate() + .animation() 声明式动画 状态驱动动画,无需手动管理动画生命周期
斑马纹列表 i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt 偶数行白色、奇数行浅米,增强列表可读性
数据可视化 零外部依赖,纯layoutWeight实现柱状图/条形图 轻量级方案,适合展示型Demo和快速原型

十四、总结与展望

14.1 架构设计总结

"筷子坊"应用虽然是一个单文件Demo,但其内部蕴含的架构思想值得认真总结:

分层清晰,职责分明。从顶部的类型定义层到底部的子组件层,每一层都有明确的单一职责。类型定义层管"契约"(19色调色板接口、5组业务实体接口、4组图表接口),常量实例层管"数据"(COLORS/TAB_LIST/WEEK_SOLD等),响应式类管"状态"(ChopstickData五组数组),工具函数层管"逻辑"(5个颜色映射纯函数),入口组件管"路由"(Tab分发+弹窗控制),子组件管"展示"(5个内容页+4个弹窗)。这种分层使得无论是添加新功能、修bug、还是做性能优化,都能快速定位到正确的层次。

接口先行,类型安全。所有数据结构都先定义接口,再实现具体数据。TypeScript编译器在编译阶段就能捕获大部分类型错误,大大降低了运行时出错的概率。19色调色板接口、5组业务实体接口、4组图表接口——合计超过10个接口定义,构筑了坚实的类型安全防线。特别是ChopstickPalette接口的19个字段,每一个都有明确的语义注释,既是代码规范也是领域知识文档。

数据驱动,声明式渲染。UI完全由状态驱动,没有命令式的DOM操作。ForEach遍历数据数组生成列表,if条件根据状态渲染不同组件,@Observed/@ObjectLink自动追踪数据变化并刷新UI。这种范式让代码更接近"描述UI应该是什么样子"而非"如何一步步构建UI"。整个应用的UI可以被看作是状态到视图的一个纯函数映射——给定相同的ChopstickDatacurTab,必然产生完全相同的界面。

14.2 技术亮点回顾

回顾全文,以下几个技术点尤其值得关注和借鉴:

  1. 19色东方木作色彩体系:以"乌木黑·朱漆红·鎏金黄·竹青绿·米纸底"为核心的四色系设计,配合精细的透明度梯度(5%/8%/18%/20%/25%/33%/40%/50%),在视觉上完美还原了传统木作工艺的温润质感与东方美学意境。接口约束 + 常量实例化的双层设计,兼顾了类型安全和运行效率。gold和silkWhite两个专属装饰色的引入,让Header区域的点缀元素有了独立的色彩表达空间而不必占用功能性颜色。
  2. @Observed + @ObjectLink的深度运用:将全部业务数据(12×5=60条记录)封装在一个可观察类中,通过@ObjectLink引用传递给5个子组件,实现了真正的"单一数据源 + 双向绑定"。任何数据变化都会自动级联刷新所有相关UI,无需手动编写事件监听。这种模式在多Tab共享数据的场景下尤为高效。
  3. 5个纯函数颜色映射层getWoodColor/getRestColor/getPriceColor/getOrderColor/getReviewTagColor——五个无副作用的颜色映射函数,将业务规则(木材种类/精度等级/价格区间/订单金额/评价标签)映射到视觉语义(颜色情感)。逻辑集中在函数体内,可独立测试、可灵活调整阈值、可在多个组件间复用。
  4. layoutWeight驱动的零依赖数据可视化:不依赖任何第三方图表库,仅用ArkTS内置的layoutWeight属性就实现了4种精美的数据可视化组件(垂直柱状图1种 + 水平条形图3种)。layoutWeight(dataValue) + layoutWeight(maxValue - dataValue)的组合模式简洁而强大,是ArkTS声明式UI中进行比例布局和数据可视化的"瑞士军刀"。
  5. 统一的弹窗交互规范:4个弹窗共享完全一致的结构模板(40%黑色遮罩 + 86%宽居中卡片 + 16px圆角 + 标题栏 + 内容区 + 操作栏),仅在内容细节和按钮文案/颜色上体现各自的功能特性。DeleteRestModal的危险色确认按钮、DetailBoxModal的文化介绍文案、EditChopstickOrderModal的数量箭头符号——这些差异化细节让统一的模板不失个性。
  6. Header区域的装饰性编程:CHOPSTICK_COL数组驱动的12点位装饰(r%3三态循环)和分隔线标语的设计,展现了如何在功能性UI中注入文化审美。这些装饰元素并非纯粹的视觉花瓶——它们强化了"筷子坊"的品牌调性,让用户在打开App的第一时间就能感受到浓厚的东方文化氛围。

14.3 优化建议与展望

尽管本应用在架构和实现上已经相当成熟,但从工程实践的角度,仍有以下几点可以进一步优化:

第一,状态管理的精细化。当前10个@State变量全部集中在根组件中,随着功能增长可能导致"状态爆炸"。建议引入轻量级状态管理方案(如ArkTS的@Provide/@ConsumeAppStorage),将弹窗状态下沉到各自的父组件中(例如showDeleteRestdelRestName可以下沉到RestContent组件内部),根组件只保留全局路由状态(curTab)和共享数据源(data)。这样既能减少根组件的状态负担,也能提升各功能模块的内聚性。

第二,网络数据对接的准备。当前所有数据均为静态硬编码。ChopstickData类的数组属性已经为未来对接真实后端API留出了清晰的接口——只需将静态初始化改为异步fetch调用(如在aboutToAppear生命周期中发起网络请求),UI层无需任何修改。这种"函数化的封装方式"体现了良好的架构前瞻性。建议未来对接时考虑添加loading状态和error boundary,提升用户体验的健壮性。

第三,组件的进一步拆分。当前每个内容组件(如ChopstickContent)内部同时包含了图表卡片和列表卡片两种UI形态。如果未来需要单独复用"周销量图表"或"筷子陈列列表",可以考虑将它们拆分为独立的子组件(WeeklySalesChartChopstickList),提升复用性。图表组件可以接受通用的数据接口参数,实现真正的"配置化图表"。

第四,无障碍访问(Accessibility)的支持。当前组件未设置accessibilityText等无障碍属性。对于一款面向文化传播的应用,确保视障用户也能通过屏幕阅读器获取筷子/筷架/礼盒的详细信息,是产品完善度的重要维度。建议为每个列表项卡片和Tab项添加语义化的无障碍描述。

第五,国际化(i18n)的预留。虽然"筷子坊"面向中文用户,但如果未来需要推出多语言版本(如面向海外华人和中国文化爱好者的英文版/日文版),建议将所有硬编码的中文字符串提取到资源文件(string.json)中,通过$r()引用。当前的TAB_LIST、静态数据中的中文字符串(如产地"龙泉"/"和田"、工艺"髹漆"/"大漆"、评价内容等)都需要做相应改造。

第六,动画系统的丰富化。当前仅有两个简单的交互动画(筷子旋转、灯笼浮动)。可以考虑为列表项添加入场动画(staggered animation)、为Tab切换添加滑动过渡、为弹窗添加缩放+淡入的组合动画。ArkTS的animateTo API和animation属性已经提供了完善的支持,只需少量代码即可显著提升交互的精致感。

总而言之,"筷子坊"应用以其精巧的东方木作色彩体系、严谨的类型安全设计、高效的响应式数据流和优雅的组件化架构,为我们提供了一个学习鸿蒙ArkTS声明式UI开发的优秀范本。从"能写出能跑的代码"到"写出架构清晰、可维护、可扩展的代码",这正是每一位鸿蒙开发者应当追求的成长路径。愿这篇深度解析能为你的进阶之路提供一份有价值的参考——正如一双好筷子能让美食更美味,好的代码架构也能让开发之旅更加顺畅愉悦。


十五、附录:DevEco Studio 安装与环境搭建

为方便读者将本文分析的"筷子坊"代码真正跑起来,下面补充一份 DevEco Studio 的安装与环境配置简明教程(配图来源于官方文档)。

安装DevEco Studio程序

在这里插入图片描述

选择目标安装目录:

在这里插入图片描述

设置环境变量,设置完务必重启:

在这里插入图片描述

新建一个空白模板:

在这里插入图片描述

设置API为24的模板项目:

在这里插入图片描述

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

在这里插入图片描述

完整代码

interface ChopstickPalette {
  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;
  gold: string;
  silkWhite: string;
}

const COLORS: ChopstickPalette = {
  primary: '#2F2A26',
  primaryLight: '#6A5F55',
  primaryDark: '#1E1A17',
  accent: '#B03A2E',
  accentLight: '#D0887E',
  bg: '#F4F0E8',
  cardBg: '#FFFFFF',
  cardAlt: '#EDE6D8',
  textPrimary: '#2A231C',
  textSecondary: '#75695B',
  textHint: '#A89A88',
  border: '#DCD2BE',
  line: '#EAE2D2',
  success: '#8FAE7B',
  warning: '#C98A4E',
  danger: '#8E2F24',
  white: '#FFFFFF',
  gold: '#D4A24E',
  silkWhite: '#F7F3E9'
};

enum ChopstickTab {
  CHOPSTICK = 0,
  REST = 1,
  BOX = 2,
  ORDER = 3,
  REVIEW = 4
}

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

const TAB_LIST: TabMeta[] = [
  { key: 'chopstick', icon: '🥢', label: '筷子', color: '#2F2A26' },
  { key: 'rest', icon: '🥄', label: '筷架', color: '#B03A2E' },
  { key: 'box', icon: '🎁', label: '礼盒', color: '#D4A24E' },
  { key: 'order', icon: '📦', label: '订单', color: '#8FAE7B' },
  { key: 'review', icon: '⭐', label: '客评', color: '#C98A4E' }
];

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

interface ChopstickItem {
  name: string;
  wood: string;
  craft: string;
  price: number;
  emoji: string;
}

interface RestItem {
  name: string;
  material: string;
  level: number;
  place: string;
  emoji: string;
}

interface BoxItem {
  name: string;
  style: string;
  level: number;
  place: string;
  emoji: string;
}

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

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

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

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

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

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

const WEEK_SOLD: WeekChopstickMeta[] = [
  { day: '周一', value: 24 },
  { day: '周二', value: 31 },
  { day: '周三', value: 39 },
  { day: '周四', value: 46 },
  { day: '周五', value: 58 },
  { day: '周六', value: 68 },
  { day: '周日', value: 61 }
];

const WOOD_SHARE: WoodShareMeta[] = [
  { name: '黑檀', count: 5, color: '#2F2A26' },
  { name: '紫檀', count: 4, color: '#B03A2E' },
  { name: '竹', count: 3, color: '#8FAE7B' },
  { name: '花梨', count: 2, color: '#D4A24E' }
];

const CHOPSTICK_HOT: ChopstickHotMeta[] = [
  { name: '整木旋削', heat: 97, color: '#2F2A26' },
  { name: '大漆髹涂', heat: 95, color: '#B03A2E' },
  { name: '金银绘纹', heat: 94, color: '#D4A24E' },
  { name: '竹节雕刻', heat: 92, color: '#8FAE7B' },
  { name: '镶嵌螺钿', heat: 91, color: '#6A5F55' },
  { name: '火绘烙花', heat: 90, color: '#C98A4E' }
];

const CHOPSTICK_TOP: ChopstickTopMeta[] = [
  { name: '黑檀箸', sold: 97, color: '#2F2A26' },
  { name: '朱漆箸', sold: 93, color: '#B03A2E' },
  { name: '金绘箸', sold: 90, color: '#D4A24E' },
  { name: '竹节箸', sold: 87, color: '#8FAE7B' },
  { name: '紫檀箸', sold: 84, color: '#6A5F55' },
  { name: '花梨箸', sold: 80, color: '#C98A4E' }
];

@Observed
export class ChopstickData {
  chopsticks: ChopstickItem[] = [
    { name: '黑檀箸', wood: '乌木', craft: '髹漆', price: 388, emoji: '🥢' },
    { name: '朱漆箸', wood: '红檀', craft: '大漆', price: 288, emoji: '🔴' },
    { name: '金绘箸', wood: '黑檀', craft: '金绘', price: 468, emoji: '✨' },
    { name: '竹节箸', wood: '楠竹', craft: '雕刻', price: 168, emoji: '🎋' },
    { name: '紫檀箸', wood: '紫檀', craft: '整木', price: 588, emoji: '🟣' },
    { name: '花梨箸', wood: '花梨', craft: '整木', price: 328, emoji: '🟠' },
    { name: '酸枝箸', wood: '酸枝', craft: '髹漆', price: 428, emoji: '🟤' },
    { name: '乌木箸', wood: '乌木', craft: '整木', price: 518, emoji: '⚫' },
    { name: '红木箸', wood: '红木', craft: '旋削', price: 258, emoji: '🟥' },
    { name: '湘妃竹箸', wood: '湘妃竹', craft: '烙花', price: 348, emoji: '🦌' },
    { name: '骨瓷箸', wood: '骨瓷', craft: '烧制', price: 198, emoji: '⚪' },
    { name: '木胎漆箸', wood: '黑檀', craft: '螺钿', price: 668, emoji: '🖌️' }
  ];

  rests: RestItem[] = [
    { name: '陶瓷箸枕', material: '青瓷', level: 96, place: '龙泉', emoji: '🏺' },
    { name: '玉箸枕', material: '和田玉', level: 95, place: '和田', emoji: '💚' },
    { name: '琉璃箸枕', material: '琉璃', level: 94, place: '博山', emoji: '🔮' },
    { name: '木箸枕', material: '花梨', level: 93, place: '海南', emoji: '🪵' },
    { name: '竹箸枕', material: '楠竹', level: 92, place: '安吉', emoji: '🎋' },
    { name: '铜箸枕', material: '黄铜', level: 94, place: '大理', emoji: '🥉' },
    { name: '瓷绘箸枕', material: '粉彩', level: 93, place: '景德镇', emoji: '🎨' },
    { name: '石箸枕', material: '青石', level: 91, place: '泰山', emoji: '🪨' },
    { name: '骨瓷箸枕', material: '骨瓷', level: 95, place: '唐山', emoji: '⚪' },
    { name: '银箸枕', material: '足银', level: 92, place: '大理', emoji: '🥈' },
    { name: '漆箸枕', material: '大漆', level: 94, place: '福州', emoji: '🖌️' },
    { name: '玛瑙箸枕', material: '玛瑙', level: 93, place: '阜新', emoji: '💎' }
  ];

  boxes: BoxItem[] = [
    { name: '婚庆礼盒', style: '龙凤', level: 96, place: '苏州', emoji: '💒' },
    { name: '寿宴礼盒', style: '福寿', level: 95, place: '杭州', emoji: '🎂' },
    { name: '乔迁礼盒', style: '如意', level: 94, place: '南京', emoji: '🏠' },
    { name: '商务礼盒', style: '山水', level: 95, place: '上海', emoji: '💼' },
    { name: '伴手礼盒', style: '花鸟', level: 93, place: '扬州', emoji: '🛍️' },
    { name: '节庆礼盒', style: '祥云', level: 94, place: '北京', emoji: '🏮' },
    { name: '满月礼盒', style: '百子', level: 93, place: '无锡', emoji: '👶' },
    { name: '谢师礼盒', style: '松鹤', level: 94, place: '绍兴', emoji: '🎓' },
    { name: '年会礼盒', style: '鎏金', level: 95, place: '深圳', emoji: '🎊' },
    { name: '旅行筷盒', style: '竹纹', level: 92, place: '安吉', emoji: '🧳' },
    { name: '便携筷盒', style: '素木', level: 91, place: '宁波', emoji: '🎒' },
    { name: '收藏筷盒', style: '雕花', level: 96, place: '东阳', emoji: '🗃️' }
  ];

  orders: ChopstickOrderItem[] = [
    { name: '国宴餐厅', region: '华北', count: 1200, amount: 720000, emoji: '🏛️' },
    { name: '日料连锁', region: '华东', count: 900, amount: 540000, emoji: '🍣' },
    { name: '高端酒店', region: '华东', count: 1100, amount: 660000, emoji: '🏨' },
    { name: '文创品牌', region: '华北', count: 800, amount: 480000, emoji: '🏯' },
    { name: '海外中餐', region: '海外', count: 600, amount: 540000, emoji: '🌏' },
    { name: '直播电商', region: '华东', count: 1000, amount: 500000, emoji: '📺' },
    { name: '婚庆公司', region: '华南', count: 750, amount: 450000, emoji: '💒' },
    { name: '航空配餐', region: '华北', count: 850, amount: 425000, emoji: '✈️' },
    { name: '连锁面馆', region: '华中', count: 950, amount: 380000, emoji: '🍜' },
    { name: '伴手礼店', region: '西南', count: 700, amount: 350000, emoji: '🎁' },
    { name: '企业定制', region: '华北', count: 500, amount: 400000, emoji: '💼' },
    { name: '米其林餐厅', region: '华东', count: 400, amount: 480000, emoji: '⭐' }
  ];

  reviews: ChopstickReviewItem[] = [
    { name: '国宴主厨', score: 5, date: '09-03', content: '黑檀箸握感沉稳,夹菜即知好木好工。', tag: '木质上乘', emoji: '🏛️' },
    { name: '日料店长', score: 5, date: '09-02', content: '朱漆箸光泽喜人,客座反馈极佳。', tag: '光泽喜人', emoji: '🍣' },
    { name: '酒店采购', score: 5, 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: '婚庆策划', score: 5, date: '08-28', content: '婚庆礼盒红金配色,宾客爱不释手。', tag: '红金喜气', emoji: '💒' },
    { name: '配餐经理', score: 4, date: '08-27', content: '竹节箸轻便环保,若加独立包装更佳。', tag: '轻便环保', emoji: '✈️' },
    { name: '面馆老板', score: 5, date: '08-26', content: '红木箸经久耐用,一年如新。', tag: '经久耐用', emoji: '🍜' },
    { name: '伴手礼主理', score: 4, date: '08-25', content: '伴手礼盒包装精美,若出小号更贴市场。', tag: '包装精美', emoji: '🎁' },
    { name: '企业行政', score: 5, date: '08-24', content: '商务礼盒刻字定制,客户反馈很好。', tag: '定制贴心', emoji: '💼' },
    { name: '老主顾', score: 5, date: '08-23', content: '收藏筷盒集齐四季,一箸一筷皆是情。', tag: '一箸情深', emoji: '⭐' }
  ];
}

function getWoodColor(wood: string): string {
  if (wood === '乌木' || wood === '黑檀') {
    return '#2F2A26';
  } else if (wood === '紫檀' || wood === '酸枝' || wood === '红木' || wood === '红檀') {
    return '#B03A2E';
  } else if (wood === '花梨') {
    return '#D4A24E';
  } else if (wood === '楠竹' || wood === '湘妃竹') {
    return '#8FAE7B';
  }
  return '#6A5F55';
}

function getRestColor(level: number): string {
  if (level >= 95) {
    return '#D4A24E';
  } else if (level >= 93) {
    return '#B03A2E';
  }
  return '#8FAE7B';
}

function getPriceColor(price: number): string {
  if (price >= 500) {
    return '#B03A2E';
  } else if (price >= 300) {
    return '#D4A24E';
  } else if (price >= 200) {
    return '#8FAE7B';
  }
  return '#6A5F55';
}

function getOrderColor(amount: number): string {
  if (amount >= 600000) {
    return '#B03A2E';
  } else if (amount >= 450000) {
    return '#D4A24E';
  } else if (amount >= 350000) {
    return '#8FAE7B';
  }
  return '#6A5F55';
}

function getReviewTagColor(tag: string): string {
  if (tag === '木质上乘' || tag === '稳定耐热' || tag === '经久耐用' || tag === '一箸情深') {
    return '#2F2A26';
  } else if (tag === '光泽喜人' || tag === '红金喜气' || tag === '纹样雅致') {
    return '#B03A2E';
  } else if (tag === '体面大方' || tag === '走量飞快' || tag === '轻便环保' || tag === '包装精美') {
    return '#8FAE7B';
  } else if (tag === '定制贴心') {
    return '#D4A24E';
  }
  return '#C98A4E';
}

@Entry
@Component
struct ChopstickApp {
  @State curTab: number = 0;
  @State data: ChopstickData = new ChopstickData();
  @State showAddChopstick: boolean = false;
  @State showEditOrder: boolean = false;
  @State showDeleteRest: boolean = false;
  @State showDetailBox: boolean = false;
  @State delRestName: string = '';
  @State detailBoxName: string = '';
  @State chopstickSpin: boolean = false;
  @State lanternFloat: 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('Chopsticks · 一箸千年 五味调和')
                .fontSize(10)
                .fontColor(COLORS.primaryLight)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Row() {
              Text('🥢')
                .fontSize(18)
                .onClick(() => {
                  this.chopstickSpin = !this.chopstickSpin;
                })
                .rotate({ angle: this.chopstickSpin ? 90 : 0 })
                .animation({ duration: 650, curve: Curve.EaseOut })
              Text('🏮')
                .fontSize(16)
                .margin({ left: 10 })
                .onClick(() => {
                  this.lanternFloat = !this.lanternFloat;
                })
                .translate({ x: 0, y: this.lanternFloat ? -10 : 4 })
                .animation({ duration: 640, curve: Curve.EaseOut })
              Text('🎁')
                .fontSize(14)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(COLORS.primaryLight + '2E')
            .borderRadius(24)
            .border({ width: 1, color: COLORS.primaryLight + '80' })
          }
          .width('100%')

          Row() {
            ForEach(CHOPSTICK_COL, (r: number) => {
              Row() {
                if (r % 3 === 0) {
                  Column()
                    .width(10)
                    .height(12)
                    .backgroundColor(COLORS.gold)
                    .borderRadius(2)
                } else if (r % 3 === 1) {
                  Column()
                    .width(8)
                    .height(8)
                    .backgroundColor(COLORS.accent)
                    .borderRadius(4)
                } else {
                  Column()
                    .width(8)
                    .height(8)
                    .backgroundColor(COLORS.success)
                    .borderRadius(4)
                }
              }
              .width(14)
              .justifyContent(FlexAlign.Center)
            }, (r: number) => 'chopstick' + 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 === ChopstickTab.CHOPSTICK) {
          ChopstickContent({ data: this.data, onAdd: () => {
            this.showAddChopstick = true;
          } })
        } else if (this.curTab === ChopstickTab.REST) {
          RestContent({ data: this.data, onDel: (n: string) => {
            this.delRestName = n;
            this.showDeleteRest = true;
          } })
        } else if (this.curTab === ChopstickTab.BOX) {
          BoxContent({ data: this.data, onDetail: (n: string) => {
            this.detailBoxName = n;
            this.showDetailBox = true;
          } })
        } else if (this.curTab === ChopstickTab.ORDER) {
          ChopstickOrderContent({ data: this.data, onEdit: () => {
            this.showEditOrder = true;
          } })
        } else {
          ChopstickReviewContent({ data: this.data })
        }
      }
      .width('100%')
      .height('100%')

      Column() {
        Column()
          .width('100%')
          .height(3)
          .backgroundColor(COLORS.accent)
        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.showAddChopstick) {
        AddChopstickModal({
          onClose: () => {
            this.showAddChopstick = false;
          }
        })
      }
      if (this.showEditOrder) {
        EditChopstickOrderModal({
          onClose: () => {
            this.showEditOrder = false;
          }
        })
      }
      if (this.showDeleteRest) {
        DeleteRestModal({
          title: this.delRestName, onClose: () => {
            this.showDeleteRest = false;
          }
        })
      }
      if (this.showDetailBox) {
        DetailBoxModal({
          name: this.detailBoxName, onClose: () => {
            this.showDetailBox = false;
          }
        })
      }
    }
  }
}

@Component
struct ChopstickTag {
  @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(10)
      .border({ width: 1, color: this.color + '40' })
  }
}

@Component
struct ChopstickContent {
  @ObjectLink data: ChopstickData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Column()
              .width(12)
              .height(4)
              .backgroundColor(COLORS.gold)
              .borderRadius(2)
            Column()
              .layoutWeight(1)
              .height(2)
              .backgroundColor(COLORS.accent + '55')
              .margin({ left: 4, right: 4 })
            Column()
              .width(6)
              .height(6)
              .backgroundColor(COLORS.success)
              .borderRadius(3)
              .margin({ right: 4 })
            Column()
              .width(12)
              .height(4)
              .backgroundColor(COLORS.accent)
              .borderRadius(2)
            Column()
              .layoutWeight(1)
              .height(2)
              .backgroundColor(COLORS.accent + '55')
              .margin({ left: 4, right: 4 })
            Column()
              .width(12)
              .height(4)
              .backgroundColor(COLORS.gold)
              .borderRadius(2)
          }
          .width('100%')
          .margin({ bottom: 10 })
          Row() {
            Text('📈 本周筷子销量')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六68双')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_SOLD, (w: WeekChopstickMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 60 ? COLORS.accent : COLORS.gold)
                Column()
                  .width(16)
                  .height(w.value)
                  .backgroundColor(w.value >= 60 ? COLORS.accent : COLORS.gold)
                  .borderRadius(2)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekChopstickMeta) => w.day)
          }
          .width('100%')
          .height(150)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .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.chopsticks, (p: ChopstickItem, i: number) => {
            Row() {
              Column() {
                Column() {
                  Text(p.emoji)
                    .fontSize(18)
                    .textAlign(TextAlign.Center)
                }
                .width(44)
                .height(44)
                .justifyContent(FlexAlign.Center)
                .alignItems(HorizontalAlign.Center)
                .backgroundColor(getWoodColor(p.wood) + '14')
                .borderRadius(10)
                .border({ width: 1, color: getWoodColor(p.wood) + '66' })
                Row() {
                  Column()
                    .width(20)
                    .height(3)
                    .backgroundColor(COLORS.gold)
                    .borderRadius(2)
                  Column()
                    .width(6)
                    .height(6)
                    .backgroundColor(getWoodColor(p.wood))
                    .borderRadius(3)
                    .margin({ left: 4 })
                }
                .width(32)
                .justifyContent(FlexAlign.Center)
              }
              .width(60)
              .padding({ top: 10, bottom: 6 })
              .backgroundColor(getWoodColor(p.wood) + '0D')
              .borderRadius(10)
              .border({ width: 1, color: getWoodColor(p.wood) + '33' })

              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() {
                  ChopstickTag({ text: p.wood, color: getWoodColor(p.wood) })
                  ChopstickTag({ text: p.craft, color: COLORS.success })
                  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(12)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: ChopstickItem, i: number) => 'cp' + 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 RestContent {
  @ObjectLink data: ChopstickData;
  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(CHOPSTICK_TOP, (t: ChopstickTopMeta) => {
            Row() {
              Text(t.name)
                .fontSize(10)
                .fontColor(COLORS.textPrimary)
                .width(90)
              Row() {
                Column()
                  .layoutWeight(t.sold)
                  .height(12)
                  .backgroundColor(t.color)
                  .borderRadius(6)
                Column()
                  .layoutWeight(100 - t.sold)
                  .height(12)
                  .backgroundColor(COLORS.line)
                  .borderRadius(6)
              }
              .layoutWeight(1)
              Text(t.sold + '双')
                .fontSize(9)
                .fontColor(t.color)
                .width(40)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (t: ChopstickTopMeta) => t.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .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.rests, (c: RestItem, i: number) => {
            Row() {
              Column() {
                Text(c.emoji)
                  .fontSize(18)
                  .textAlign(TextAlign.Center)
                Row() {
                  Column()
                    .width(14)
                    .height(4)
                    .backgroundColor(getRestColor(c.level))
                    .borderRadius(2)
                  Column()
                    .width(8)
                    .height(8)
                    .backgroundColor(COLORS.silkWhite)
                    .borderRadius(4)
                    .margin({ left: 4 })
                }
                .width(30)
                .justifyContent(FlexAlign.Center)
                .margin({ top: 5 })
              }
              .width(56)
              .padding({ top: 10, bottom: 6 })
              .backgroundColor(getRestColor(c.level) + '0D')
              .borderRadius(10)
              .border({ width: 1, color: getRestColor(c.level) + '33' })

              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(c.material)
                    .fontSize(9)
                    .fontColor(COLORS.textSecondary)
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                Row() {
                  Text('承托精度')
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                  Row() {
                    Column()
                      .layoutWeight(c.level)
                      .height(6)
                      .backgroundColor(getRestColor(c.level))
                      .borderRadius(3)
                    Column()
                      .layoutWeight(100 - c.level)
                      .height(6)
                      .backgroundColor(COLORS.line)
                      .borderRadius(3)
                  }
                  .layoutWeight(1)
                  .margin({ left: 6 })
                }
                .width('100%')
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })

              Text('🗑️')
                .fontSize(14)
                .onClick(() => {
                  this.onDel(c.name);
                })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(12)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (c: RestItem, i: number) => 'rs' + c.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 BoxContent {
  @ObjectLink data: ChopstickData;
  onDetail: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🔥 制箸工艺热度')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('整木旋削97')
              .fontSize(9)
              .fontColor(COLORS.primary)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(CHOPSTICK_HOT, (d: ChopstickHotMeta) => {
            Row() {
              Text(d.name)
                .fontSize(10)
                .fontColor(COLORS.textPrimary)
                .width(80)
              Row() {
                Column()
                  .layoutWeight(d.heat)
                  .height(14)
                  .backgroundColor(d.color)
                  .borderRadius(7)
                Column()
                  .layoutWeight(100 - d.heat)
                  .height(14)
                  .backgroundColor(COLORS.line)
                  .borderRadius(7)
              }
              .layoutWeight(1)
              Text(d.heat + '')
                .fontSize(9)
                .fontColor(d.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (d: ChopstickHotMeta) => d.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .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.boxes, (b: BoxItem, i: number) => {
            Row() {
              Column() {
                Text(b.emoji)
                  .fontSize(18)
                  .textAlign(TextAlign.Center)
                Column()
                  .width(14)
                  .height(4)
                  .backgroundColor(getRestColor(b.level))
                  .borderRadius(2)
                  .margin({ top: 5 })
              }
              .width(56)
              .padding({ top: 10, bottom: 6 })
              .backgroundColor(getRestColor(b.level) + '0D')
              .borderRadius(10)
              .border({ width: 1, color: getRestColor(b.level) + '33' })

              Column() {
                Row() {
                  Text(b.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text('品级' + b.level)
                    .fontSize(9)
                    .fontColor(getRestColor(b.level))
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                Row() {
                  ChopstickTag({ text: b.style + '纹', color: COLORS.textSecondary })
                  Text(b.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 })

              Text('👁️')
                .fontSize(14)
                .onClick(() => {
                  this.onDetail(b.name);
                })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(12)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (b: BoxItem, i: number) => 'bx' + b.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 ChopstickOrderContent {
  @ObjectLink data: ChopstickData;
  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(WOOD_SHARE, (s: WoodShareMeta) => {
            Row() {
              Column()
                .width(8)
                .height(8)
                .backgroundColor(s.color)
                .borderRadius(4)
              Text(s.name)
                .fontSize(10)
                .fontColor(COLORS.textPrimary)
                .margin({ left: 6 })
              Text('×' + s.count)
                .fontSize(9)
                .fontColor(COLORS.textSecondary)
                .margin({ left: 4 })
              Row() {
                Column()
                  .layoutWeight(s.count)
                  .height(10)
                  .backgroundColor(s.color)
                  .borderRadius(5)
                Column()
                  .layoutWeight(20 - s.count)
                  .height(10)
                  .backgroundColor(COLORS.line)
                  .borderRadius(5)
              }
              .layoutWeight(1)
              .margin({ left: 10 })
            }
            .width('100%')
            .margin({ top: 6 })
          }, (s: WoodShareMeta) => s.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .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.onEdit();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.orders, (o: ChopstickOrderItem, i: number) => {
            Row() {
              Text(o.emoji)
                .fontSize(16)
              Column() {
                Text(o.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(o.region + ' · ' + o.count + '双')
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Text('¥' + o.amount)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(getOrderColor(o.amount))
              Text('✏️')
                .fontSize(12)
                .margin({ left: 10 })
                .onClick(() => {
                  this.onEdit();
                })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(12)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (o: ChopstickOrderItem, 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 ChopstickReviewContent {
  @ObjectLink data: ChopstickData;

  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: ChopstickReviewItem, 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)
              ChopstickTag({ 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(12)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 8 })
        }, (r: ChopstickReviewItem, i: number) => 'rv' + r.name + i)
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct AddChopstickModal {
  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(10)
        .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(10)
        .margin({ top: 8 })
        Row() {
          Text('预算')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('¥388')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(10)
        .margin({ top: 8 })
        Row() {
          Text('取消')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(12)
            .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(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 14 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct EditChopstickOrderModal {
  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(10)
        .margin({ top: 14 })
        Row() {
          Text('数量')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('1200 → 1300 双')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(10)
        .margin({ top: 8 })
        Row() {
          Text('金额')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('¥780000')
            .fontSize(11)
            .fontColor(COLORS.accent)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(10)
        .margin({ top: 8 })
        Row() {
          Text('取消')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(12)
            .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(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 14 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct DeleteRestModal {
  @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(12)
            .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(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 16 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct DetailBoxModal {
  @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(12)
        .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(10)
        .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(10)
        .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(12)
          .margin({ top: 12 })
          .onClick(() => {
            this.onClose();
          })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}
Logo

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

更多推荐