摘要:本文以一个完整的非遗扎染文化展示应用为案例,系统性地拆解鸿蒙HarmonyOS ArkTS声明式UI的开发实践。从靛蓝色彩体系的接口化建模,到@Observed/@ObjectLink响应式数据流的构建,再到多Tab路由管理、自定义弹窗体系、纯代码图表渲染等核心场景的完整实现,逐行解析每一处代码背后的工程决策与设计意图。全文覆盖接口约束、枚举路由、数据建模、状态驱动、组件拆分、动画交互等16+个技术知识点,适合有ArkTS基础、希望深入掌握鸿蒙声明式UI架构设计思想的开发者进阶研读。


一、前言:扎染艺术的千年传承与数字化表达

扎染,古称扎缬、绞缬,是中国古老的纺织品染色工艺之一,距今已有超过一千五百年的历史。其工艺核心在于"先扎后染"——用线绳将布料按设计意图捆扎、缝缀、折叠,再浸入染缸,扎结处染液无法渗入,拆线后便在布面上留下深浅不一、变幻莫测的花纹。每一块扎染布都是独一无二的,这种"一布千纹"的不可复制性,正是扎染艺术最迷人的魅力所在。

在移动互联网时代,将扎染这一非遗技艺以移动应用的形式呈现,不仅是对传统文化的数字化保护与传播,更是对鸿蒙HarmonyOS ArkTS开发能力的一次全面检验。本文所分析的应用,围绕"扎染坊"这一主题,构建了一个涵盖扎染品陈列、布料管理、染液工艺、订单台账与客户口碑评价的综合性展示平台。应用以靛蓝为主色调,从视觉层面还原了蓝靛染缸的深邃与雅致,让用户在指尖滑动间感受千年染艺的温度。

从技术角度而言,这个应用虽然主题是传统的,但其架构设计却是现代化的:采用接口驱动的类型安全体系、@Observed/@ObjectLink的响应式数据流、条件渲染的页面路由、回调驱动的组件通信、纯函数的颜色计算逻辑——这些技术手段共同构建了一个既优雅又高效的应用架构。本文将从色彩体系开始,逐层深入,带你完整走过一次从设计到实现的技术旅程。


二、应用架构全景:从色彩到组件的完整设计

在深入代码细节之前,我们先用一张架构全景表来梳理整个应用的模块划分:

架构模块 核心职责 技术手段 代码组织方式
色彩体系 全局配色统一管理 interface + const 接口约束 + 常量实例
路由枚举 Tab页面索引管理 enum枚举 语义化常量
标签配置 底部导航栏数据源 interface + const数组 数据驱动UI
业务实体 五种核心数据结构 interface接口定义 类型安全契约
图表元数据 四类图表数据结构 interface接口定义 最小字段集
静态数据 不可变的基础数据集 const常量数组 模块级常量
响应式数据 可观察的业务数据 @Observed类 构造即初始化
工具函数 颜色计算等纯逻辑 纯函数 无副作用
主入口组件 应用骨架与路由控制 @Entry + @Component 三段式布局
内容组件 各Tab页内容渲染 @Component + @ObjectLink 数据双向绑定
弹窗组件 模态对话框 @Component + @Prop 条件渲染控制

这种架构设计遵循了"关注点分离"的核心原则:数据定义与UI渲染分离、静态数据与动态状态分离、业务逻辑与视图表现分离。每一层都可以独立开发、独立测试、独立维护,极大地提升了代码的可维护性和可扩展性。


三、靛蓝色彩体系:从接口到实例的完整链路

3.1 调色板接口设计

色彩是移动应用的灵魂。对于扎染主题的应用,色彩的选择不仅要美观,更要能传达出蓝靛染缸的深邃与植物染料的自然感。应用在色彩管理上采用了"接口定义结构、常量填充实例"的双层模式:

interface TieDyePalette {
  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;
  indigoBlue: string;
  dyeWhite: string;
}

TieDyePalette接口定义了19个颜色字段,其结构设计与Material Design的色彩规范一脉相承,但做了扎染主题的深度定制。特别值得关注的是最后两个字段:indigoBlue(靛蓝)和dyeWhite(染布白),它们是扎染主题的专属色彩——靛蓝代表了蓝靛染液的颜色,染布白则代表了未染色的胚布底色。这两个专属色的加入,使得整个色彩体系与扎染工艺建立了深度的语义关联。

三级文本色(textPrimary、textSecondary、textHint)的设计同样值得称道。在移动端UI设计中,信息层级是用户体验的基础:标题用textPrimary(深色,权重最高),辅助信息用textSecondary(中等灰度,次级权重),提示性文字用textHint(浅灰,最低权重)。这种三级灰度系统让用户在扫视页面时就能本能地分清信息的主次关系。

3.2 色彩常量实例

const COLORS: TieDyePalette = {
  primary: '#3E5C8A',
  primaryLight: '#6B85B0',
  primaryDark: '#27405F',
  accent: '#7A9BB8',
  accentLight: '#B4C8DC',
  bg: '#EEF2F6',
  cardBg: '#FFFFFF',
  cardAlt: '#E3EAF2',
  textPrimary: '#24324A',
  textSecondary: '#5E6E88',
  textHint: '#8FA0B8',
  border: '#D3DEEA',
  line: '#E9EFF5',
  success: '#5E8A6E',
  warning: '#C98A4E',
  danger: '#9E2E20',
  white: '#FFFFFF',
  indigoBlue: '#3E5C8A',
  dyeWhite: '#F4F0E4'
};

这段代码将抽象的色彩接口转化为具体的色值。COLORS常量的类型被显式标注为TieDyePalette,这是一个极其重要的类型安全措施——TypeScript编译器会在编译阶段验证COLORS对象是否完整实现了接口定义的所有19个字段。如果遗漏任何一个字段,或者字段类型不匹配,编译器会立即报错,将潜在的类型错误消灭在编译期。

从色彩美学角度分析,这套配色方案以#3E5C8A(靛蓝)为主色调,搭配#7A9BB8(蓝灰)作为强调色,营造了一种沉静、内敛、深邃的视觉氛围。背景色#EEF2F6是一种极浅的蓝灰色,与主色调形成微妙的呼应。dyeWhite的色值#F4F0E4并非纯白,而是一种带有暖黄色调的米白,这模拟了天然棉布未经染色时的真实颜色,体现了设计者对扎染工艺细节的深度理解。

与常见的纯白背景不同,这种微暖的米白底色在视觉上更加柔和,长时间阅读不易产生视觉疲劳,同时也与靛蓝主色形成了经典的"蓝白对比"——这正是扎染布最经典的色彩组合。


四、枚举路由与标签配置:数据驱动的导航系统

4.1 Tab枚举定义

enum TieDyeTab {
  TIEDYE = 0,
  CLOTH = 1,
  DYE = 2,
  ORDER = 3,
  REVIEW = 4
}

枚举是ArkTS中管理常量集合的标准方式。TieDyeTab枚举定义了应用的五个标签页:扎染(TIEDYE)、布料(CLOTH)、染液(DYE)、订单(ORDER)和客评(REVIEW)。使用枚举而非魔法数字的核心理由是自文档化——当代码中出现TieDyeTab.DYE时,任何开发者都能立即理解这是染液页面;而如果写成数字2,则需要查阅文档或上下文才能理解其含义。此外,枚举还提供了编译期的类型检查——如果拼错了枚举值(如TieDyeTab.DYE写成TieDyeTab.DAY),编译器会立即报错。

4.2 标签元数据配置

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

const TAB_LIST: TabMeta[] = [
  { key: 'tiedye', icon: '🌀', label: '扎染', color: '#3E5C8A' },
  { key: 'cloth', icon: '🧵', label: '布料', color: '#6B85B0' },
  { key: 'dye', icon: '🧪', label: '染液', color: '#5E8A6E' },
  { key: 'order', icon: '📦', label: '订单', color: '#C98A4E' },
  { key: 'review', icon: '⭐', label: '客评', color: '#9E2E20' }
];

TAB_LIST是底部导航栏的数据源。每个标签项包含四个属性:key(唯一标识,用于ForEach的键值生成)、icon(emoji图标)、label(中文标签文字)和color(主题色)。这种"数据驱动UI"的设计模式,使得导航栏的配置完全脱离了组件代码——新增、删除或修改标签只需要操作TAB_LIST数组,无需修改任何组件逻辑。

每个标签拥有独立的主题色,这为选中态的视觉表现提供了个性化空间。当某个标签被选中时,其文字颜色、背景色和边框色都会切换为该标签的主题色,形成明确的视觉反馈。


五、数据模型设计:五大业务实体的接口定义

5.1 核心业务实体

interface TieDyeItem {
  name: string;
  kind: string;
  cloth: string;
  price: number;
  emoji: string;
}

interface ClothItem {
  name: string;
  weave: string;
  level: number;
  place: string;
  emoji: string;
}

interface DyeItem {
  name: string;
  plant: string;
  level: number;
  tool: string;
  emoji: string;
}

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

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

这五个接口定义了应用的核心数据结构。TieDyeItem是扎染品实体,包含品名、染法种类(扎染/夹染/蜡染/型染)、布料材质、价格和emoji图标。ClothItem是布料实体,记录布名、织法、吸染度和产地。DyeItem是染液实体,存储染料名称、植物来源、工艺等级和染色工具。TieDyeOrderItemTieDyeReviewItem则分别管理订单和评价数据。

这里有一个值得注意的设计决策:所有实体接口都包含emoji字段。在移动端UI开发中,使用emoji作为列表项的视觉标识是一种非常高效的做法——emoji是Unicode标准的一部分,所有设备都能正确渲染,无需加载额外的图片资源,也不会有不同屏幕密度的适配问题。对于扎染这种视觉属性强烈的主题,emoji(如🌀扎染纹、🧵缝线、🧪染液)能够直观地传达每条数据的视觉语义。

5.2 图表数据元接口

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

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

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

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

这四个接口分别服务于四种图表:WeekTieDyeMeta用于周销量柱状图,KindShareMeta用于染法占比条形图,DyeHotMeta用于染艺热度排行,TieDyeTopMeta用于扎染品销量TOP榜。每个接口只包含渲染对应图表所需的最小字段集——这种"接口隔离原则"确保了数据结构不携带任何冗余字段,保持了数据模型的精简和清晰。


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

6.1 周销量与染法占比

const WEEK_SOLD: WeekTieDyeMeta[] = [
  { day: '周一', value: 26 },
  { day: '周二', value: 34 },
  { day: '周三', value: 42 },
  { day: '周四', value: 50 },
  { day: '周五', value: 58 },
  { day: '周六', value: 70 },
  { day: '周日', value: 63 }
];

const KIND_SHARE: KindShareMeta[] = [
  { name: '夹染', count: 5, color: '#3E5C8A' },
  { name: '扎染', count: 4, color: '#6B85B0' },
  { name: '蜡染', count: 3, color: '#5E8A6E' },
  { name: '型染', count: 2, color: '#C98A4E' }
];

周销量数据从周一到周六逐步攀升(26→34→42→50→58→70),周日有所回落(63),这符合零售业"周末高峰"的典型规律,让数据呈现出真实的商业节奏感。染法占比数据则涵盖了四种核心染法:夹染(5件,占比最高)、扎染(4件)、蜡染(3件)和型染(2件),完整呈现了扎染工艺的技法谱系。

6.2 染艺热度与销量排行

const DYE_HOT: DyeHotMeta[] = [
  { name: '蓝靛发酵', heat: 97, color: '#3E5C8A' },
  { name: '扎花捆结', heat: 95, color: '#6B85B0' },
  { name: '浸染提色', heat: 93, color: '#5E8A6E' },
  { name: '氧化还原', heat: 92, color: '#7A9BB8' },
  { name: '漂洗固色', heat: 90, color: '#C98A4E' },
  { name: '拆线显花', heat: 88, color: '#9E2E20' }
];

const TIEDYE_TOP: TieDyeTopMeta[] = [
  { name: '蓝染方巾', sold: 97, color: '#3E5C8A' },
  { name: '扎染围巾', sold: 91, color: '#6B85B0' },
  { name: '夹染桌布', sold: 87, color: '#5E8A6E' },
  { name: '扎染连衣裙', sold: 82, color: '#7A9BB8' },
  { name: '蜡染壁挂', sold: 78, color: '#C98A4E' },
  { name: '扎染帆布包', sold: 75, color: '#9E2E20' }
];

染艺热度数据按照工艺流程的先后顺序排列——从"蓝靛发酵"(热度97,制靛阶段)到"拆线显花"(热度88,成品阶段),完整勾勒出扎染从染液制备到最终显花的六道核心工序。这种数据设计不仅服务于图表展示,更隐含了扎染工艺的科普价值——用户在浏览热度排行时,无形中就了解了扎染的完整工艺链条。


七、响应式数据类:@Observed驱动UI刷新

@Observed
export class TieDyeData {
  tiedyes: TieDyeItem[] = [
    { name: '蓝染方巾', kind: '扎染', cloth: '棉布', price: 58, emoji: '🌀' },
    { name: '扎染围巾', kind: '扎染', cloth: '真丝', price: 168, emoji: '🧣' },
    { name: '夹染桌布', kind: '夹染', cloth: '麻布', price: 128, emoji: '🍽️' },
    { name: '扎染连衣裙', kind: '扎染', cloth: '棉麻', price: 268, emoji: '👗' },
    { name: '蜡染壁挂', kind: '蜡染', cloth: '棉布', price: 198, emoji: '🖼️' },
    { name: '扎染帆布包', kind: '扎染', cloth: '帆布', price: 88, emoji: '👜' },
    { name: '扎染手帕', kind: '扎染', cloth: '棉布', price: 38, emoji: '🧻' },
    { name: '型染门帘', kind: '型染', cloth: '棉布', price: 148, emoji: '🚪' },
    { name: '扎染抱枕', kind: '扎染', cloth: '棉麻', price: 78, emoji: '🛋️' },
    { name: '扎染床旗', kind: '扎染', cloth: '棉布', price: 228, emoji: '🛏️' },
    { name: '扎染头巾', kind: '扎染', cloth: '棉布', price: 48, emoji: '🎀' },
    { name: '扎染茶席', kind: '夹染', cloth: '亚麻', price: 108, emoji: '🍵' }
  ];

  cloths: ClothItem[] = [
    { name: '细棉布', weave: '平纹', level: 96, place: '江南', emoji: '🌾' },
    { name: '真丝绡', weave: '平绡', level: 95, place: '苏杭', emoji: '🦋' },
    { name: '麻布', weave: '平纹', level: 94, place: '湘西', emoji: '🌿' },
    // ... 更多布料数据
  ];

  dyes: DyeItem[] = [
    { name: '蓝靛', plant: '蓼蓝', level: 96, tool: '发酵缸', emoji: '🔵' },
    { name: '靛青', plant: '马蓝', level: 95, tool: '打靛池', emoji: '💙' },
    { name: '苏木红', plant: '苏木', level: 93, tool: '熬煮锅', emoji: '❤️' },
    // ... 更多染液数据
  ];

  orders: TieDyeOrderItem[] = [
    { name: '民宿连锁', region: '西南', count: 800, amount: 144000, emoji: '🏡' },
    { name: '非遗工坊', region: '云南', count: 600, amount: 120000, emoji: '🏛️' },
    // ... 更多订单数据
  ];

  reviews: TieDyeReviewItem[] = [
    { name: '民宿主理人', score: 5, date: '09-03', content: '床旗扎染花色如水墨晕开,客人拍照发圈刷屏了。', tag: '花色晕染', emoji: '🛏️' },
    { name: '非遗馆长', score: 5, date: '09-02', content: '蓝靛染布色牢度高,洗了十次不掉色不褪蓝。', tag: '色牢度高', emoji: '🏛️' },
    // ... 更多评价数据
  ];
}

@Observed是ArkTS框架中最核心的装饰器之一。当一个类被@Observed标记后,框架会为该类的每个属性安装"监听器"——当属性值发生变化时,所有通过@ObjectLink引用了该实例的子组件都会收到通知,并自动触发UI的差异化更新。

TieDyeData类包含了五个数组属性,分别对应五种业务数据。每个数组在类声明时直接赋了初始值,这意味着当new TieDyeData()执行完毕时,所有数据就已经就绪——无需在aboutToAppear生命周期中异步加载,也无需处理加载状态(loading state)。这种"构造即初始化"的模式,使得数据流变得简单可预测。

从数据内容来看,tiedyes数组涵盖了12种扎染品,从最便宜的扎染手帕(¥38)到最贵的扎染连衣裙(¥268),价格梯度分明。cloths数组列出了12种布料,从细棉布到丝棉,覆盖了扎染常用的各类织物。dyes数组收录了12种植物染料,从蓝靛到竹叶青,展现了传统植物染的色谱。ordersreviews数组则分别包含了12条订单和12条评价,数据量充足,足以展示长列表的滚动效果。


八、颜色工具函数:纯函数的设计与实践

function getKindColor(kind: string): string {
  if (kind === '扎染') {
    return '#3E5C8A';
  } else if (kind === '夹染') {
    return '#6B85B0';
  } else if (kind === '蜡染') {
    return '#5E8A6E';
  }
  return '#C98A4E';
}

function getClothColor(level: number): string {
  if (level >= 95) {
    return '#3E5C8A';
  } else if (level >= 92) {
    return '#6B85B0';
  }
  return '#5E8A6E';
}

function getTieDyeColor(price: number): string {
  if (price >= 200) {
    return '#9E2E20';
  } else if (price >= 120) {
    return '#3E5C8A';
  } else if (price >= 60) {
    return '#6B85B0';
  }
  return '#5E8A6E';
}

function getOrderColor(amount: number): string {
  if (amount >= 120000) {
    return '#9E2E20';
  } else if (amount >= 90000) {
    return '#3E5C8A';
  } else if (amount >= 60000) {
    return '#6B85B0';
  }
  return '#5E8A6E';
}

function getReviewTagColor(tag: string): string {
  if (tag === '花色晕染' || tag === '沉稳雅致' || tag === '配色匀称' || tag === '纹样规整') {
    return '#3E5C8A';
  } else if (tag === '色牢度高' || tag === '扎纹别致' || tag === '独一无二') {
    return '#6B85B0';
  } else if (tag === '入镜质感' || tag === '手作温度' || tag === '绵软亲肤') {
    return '#5E8A6E';
  } else if (tag === '物料齐全' || tag === '走量飞快') {
    return '#C98A4E';
  }
  return '#9E2E20';
}

这五个函数都是纯函数——相同的输入永远产生相同的输出,不依赖也不修改任何外部状态。纯函数的四大优势在这里得到了充分体现:

可测试性:每个函数都可以独立编写单元测试,只需验证"给定输入X,输出是否为Y",无需模拟任何外部依赖。

可缓存性:由于输出只取决于输入,相同参数的调用结果可以被缓存。在实际项目中,如果这些函数被频繁调用(如在长列表渲染中),缓存可以显著提升性能。

可并行性:纯函数没有共享状态,不存在竞态条件,可以安全地并行执行。

可组合性:纯函数可以像积木一样自由组合。例如,getTieDyeColor(p.price)的返回值可以直接传给TieDyeTag组件的color属性,无需担心副作用。

从业务逻辑来看,这五个函数分别从染法种类、布料等级、价格区间、订单金额和评价标签五个维度计算颜色。值得注意的是,高价格和高金额都映射到#9E2E20(深红色),这是一种视觉警示色——在UI中,深红色天然具有吸引注意力的效果,使得高价值项在列表中更加突出。


九、主入口组件:应用骨架与三段式布局

9.1 状态声明与初始化

@Entry
@Component
struct TieDyeApp {
  @State curTab: number = 0;
  @State data: TieDyeData = new TieDyeData();
  @State showAddTieDye: boolean = false;
  @State showEditOrder: boolean = false;
  @State showDeleteCloth: boolean = false;
  @State showDetailDye: boolean = false;
  @State delClothName: string = '';
  @State detailDyeName: string = '';
  @State swirlRotate: boolean = false;
  @State dropFloat: boolean = false;

【代码截图8】主组件状态声明

@Entry装饰器标记TieDyeApp为页面的入口组件——每个ArkTS页面有且仅有一个@Entry组件。九个@State变量构成了应用的全部状态空间:

  • curTab:当前选中的Tab索引,初始为0(扎染页),驱动内容区域的条件渲染
  • dataTieDyeData实例,在声明时通过new TieDyeData()初始化,构造即就绪
  • 四个show布尔变量:分别控制四个弹窗的显隐
  • delClothNamedetailDyeName:弹窗所需的数据参数
  • swirlRotatedropFloat:头部装饰图标的动画状态

@State变量的核心特性是响应式——当变量值发生变化时,所有引用该变量的UI部分会自动重新渲染。这种"状态驱动UI"的范式,使得开发者只需关注"状态是什么",而无需手动操作DOM来更新视图。

9.2 build方法与头部交互

build() {
  Column() {
    Column() {
      Column() {
        Row() {
          Column() {
            Text('🌀 扎染坊')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
            Text('TieDye · 蓝靛晕染 一布千纹')
              .fontSize(10)
              .fontColor(COLORS.accentLight)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Row() {
            Text('🌀')
              .fontSize(20)
              .onClick(() => {
                this.swirlRotate = !this.swirlRotate;
              })
              .rotate({ angle: this.swirlRotate ? 90 : 0 })
              .animation({ duration: 700, curve: Curve.EaseOut })
            Text('💧')
              .fontSize(16)
              .margin({ left: 10 })
              .onClick(() => {
                this.dropFloat = !this.dropFloat;
              })
              .translate({ x: this.dropFloat ? 14 : -8, y: this.dropFloat ? -8 : 0 })
              .animation({ duration: 620, curve: Curve.EaseOut })
            Text('🧵')
              .fontSize(14)
              .margin({ left: 6 })
          }
          .padding({ left: 10, right: 10, top: 6, bottom: 6 })
          .backgroundColor(COLORS.accentLight + '2E')
          .borderRadius(24)
          .border({ width: 1, color: COLORS.accentLight + '80' })
        }
        .width('100%')
        // ... 装饰线与标语
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .backgroundColor(COLORS.primaryDark)

      // 内容区域 - 根据curTab条件渲染
      if (this.curTab === TieDyeTab.TIEDYE) {
        TieDyeContent({ data: this.data, onAdd: () => {
          this.showAddTieDye = true;
        } })
      } else if (this.curTab === TieDyeTab.CLOTH) {
        ClothContent({ data: this.data, onDel: (n: string) => {
          this.delClothName = n;
          this.showDeleteCloth = true;
        } })
      } else if (this.curTab === TieDyeTab.DYE) {
        DyeContent({ data: this.data, onDetail: (n: string) => {
          this.detailDyeName = n;
          this.showDetailDye = true;
        } })
      } else if (this.curTab === TieDyeTab.ORDER) {
        TieDyeOrderContent({ data: this.data, onEdit: () => {
          this.showEditOrder = true;
        } })
      } else {
        TieDyeReviewContent({ data: this.data })
      }
    }
    .width('100%')
    .height('100%')

    // 底部Tab栏
    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(14)
          .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%')
  }
}

build()方法是ArkTS组件的UI描述核心,以声明式语法描述组件的视觉结构。这里的build()方法构建了一个经典的三段式布局:顶部标题栏(深色背景) + 中间内容区(弹性填充) + 底部Tab栏(固定高度60)。

头部标题栏的右侧有三个可交互的emoji图标,每个都绑定了动画效果。点击🌀漩涡emoji触发旋转动画——rotate({ angle: this.swirlRotate ? 90 : 0 })根据状态在0度和90度之间切换,animation({ duration: 700, curve: Curve.EaseOut })定义了700毫秒的EaseOut缓动动画。点击💧水滴emoji触发位移动画——translate({ x: this.dropFloat ? 14 : -8, y: this.dropFloat ? -8 : 0 })在两个位置间切换,模拟水滴漂浮的效果。这些微交互动画虽然功能简单,却为应用增添了趣味性和生命力。

内容区域使用if-else if-else条件渲染实现Tab切换。当curTab变化时,ArkTS框架会自动销毁旧条件分支的组件树并创建新分支的组件树。每个内容组件在创建时都通过参数传递了数据(data: this.data)和回调函数(如onAddonDelonDetailonEdit)。子组件通过调用回调函数通知父组件执行操作(如打开弹窗),父组件在回调中修改状态,状态变化再驱动UI更新——这就是ArkTS中"单向数据流"的经典实现。

底部Tab栏通过ForEach遍历TAB_LIST动态生成。每个Tab项的选中态通过条件表达式动态设置:选中时文字使用该Tab的主题色并加粗,背景使用color + '14'(约8%透明度),边框使用主题色;未选中时文字使用textHint灰色,背景透明,无边框。TAB_LIST.indexOf(t)获取当前遍历项在数组中的索引,与curTab比较判断是否选中。

9.3 弹窗的条件渲染与回调关闭

if (this.showAddTieDye) {
  AddTieDyeModal({
    onClose: () => {
      this.showAddTieDye = false;
    }
  })
}
if (this.showEditOrder) {
  EditTieDyeOrderModal({
    onClose: () => {
      this.showEditOrder = false;
    }
  })
}
if (this.showDeleteCloth) {
  DeleteClothModal({
    title: this.delClothName, onClose: () => {
      this.showDeleteCloth = false;
    }
  })
}
if (this.showDetailDye) {
  DetailDyeModal({
    name: this.detailDyeName, onClose: () => {
      this.showDetailDye = false;
    }
  })
}

四个弹窗通过if条件渲染控制显隐。当show变量为true时弹窗被创建并渲染,onClose回调被调用时show变量设为false,弹窗被销毁。DeleteClothModalDetailDyeModal还通过@Prop接收额外的数据参数(titlename),实现了弹窗内容的动态化。


十、内容组件体系:数据双向绑定与纯代码图表

10.1 标签组件TieDyeTag

@Component
struct TieDyeTag {
  @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' })
  }
}

这是一个高度可复用的标签组件。@Prop装饰器接收父组件传递的只读数据——与@State不同,@Prop变量在子组件中不可修改,只能由父组件更新。标签的视觉设计使用了同一颜色的三种透明度:文字用纯色、背景用color + '14'(约8%透明度)、边框用color + '40'(约25%透明度),形成了一种柔和而统一的色彩层次。

10.2 扎染内容组件TieDyeContent

@Component
struct TieDyeContent {
  @ObjectLink data: TieDyeData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        // 周销量图表卡片
        Column() {
          Row() {
            Column().width(10).height(10).backgroundColor(COLORS.indigoBlue)
              .borderRadius(5).border({ width: 2, color: COLORS.dyeWhite })
            Column().layoutWeight(1).height(2).backgroundColor(COLORS.primary + '55')
              .margin({ left: 4, right: 4 })
            Column().width(6).height(6).backgroundColor(COLORS.dyeWhite).borderRadius(3)
              .margin({ right: 4 })
            Column().width(10).height(10).backgroundColor(COLORS.primaryLight).borderRadius(5)
            Column().layoutWeight(1).height(2).backgroundColor(COLORS.primary + '55')
              .margin({ left: 4, right: 4 })
            Column().width(10).height(10).backgroundColor(COLORS.indigoBlue)
              .borderRadius(5).border({ width: 2, color: COLORS.dyeWhite })
          }
          .width('100%')
          .margin({ bottom: 10 })
        
          Row() {
            Text('📈 本周扎染销量')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六70件')
              .fontSize(9)
              .fontColor(COLORS.primary)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
        
          Row() {
            ForEach(WEEK_SOLD, (w: WeekTieDyeMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 63 ? COLORS.primary : COLORS.accent)
                Column()
                  .width(16)
                  .height(w.value)
                  .backgroundColor(w.value >= 63 ? COLORS.primary : COLORS.accent)
                  .borderRadius(8)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekTieDyeMeta) => w.day)
          }
          .width('100%')
          .height(150)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(20)
        .border({ width: 1, color: COLORS.primary + '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.tiedyes, (p: TieDyeItem, i: number) => {
            Row() {
              Column() {
                Column() {
                  Text(p.emoji)
                    .fontSize(18)
                    .textAlign(TextAlign.Center)
                }
                .width(44)
                .height(44)
                .justifyContent(FlexAlign.Center)
                .alignItems(HorizontalAlign.Center)
                .backgroundColor(getTieDyeColor(p.price) + '14')
                .borderRadius(22)
                .border({ width: 1, color: getTieDyeColor(p.price) + '66' })
                Row() {
                  Column().width(14).height(14).backgroundColor(getTieDyeColor(p.price))
                    .borderRadius(7).border({ width: 2, color: COLORS.dyeWhite })
                  Column().width(8).height(8).backgroundColor(COLORS.dyeWhite)
                    .borderRadius(4).margin({ left: 4 })
                }
                .width(30)
                .justifyContent(FlexAlign.Center)
              }
              .width(60)
              .padding({ top: 10, bottom: 6 })
              .backgroundColor(getTieDyeColor(p.price) + '0D')
              .borderRadius(22)
              .border({ width: 1, color: getTieDyeColor(p.price) + '33' })

              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text('¥' + p.price)
                    .fontSize(10)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(getTieDyeColor(p.price))
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                Row() {
                  TieDyeTag({ text: p.kind, color: getTieDyeColor(p.price) })
                  TieDyeTag({ text: p.cloth, 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(18)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: TieDyeItem, i: number) => 'td' + p.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

TieDyeContent组件是扎染Tab页的主体内容渲染器。它使用了@ObjectLink装饰器接收父组件传递的TieDyeData实例。@ObjectLink@Prop的本质区别在于:@Prop创建的是值的拷贝,而@ObjectLink建立的是引用关系——当TieDyeData实例的属性变化时,TieDyeContent中引用这些属性的UI会自动刷新。这种机制使得数据在父子组件间实现了自动同步,无需手动触发更新。

周销量柱状图的实现是纯代码图表的经典案例——没有引入任何第三方图表库,完全使用ArkTS的基础布局组件实现。ForEach遍历WEEK_SOLD数组,为每个数据项创建一个Column:顶部是数值文本,中间是柱体(height设为数据值),底部是日期文本。柱体颜色通过条件表达式动态选择——w.value >= 63用主色,否则用强调色。alignItems(VerticalAlign.Bottom)确保所有柱体从底部对齐,形成标准的柱状图视觉效果。

卡片顶部的装饰线设计颇具匠心——五个不同大小和颜色的圆点通过layoutWeight弹性分割线连接,模拟了扎染布面上的染料斑点效果,与主题形成了视觉呼应。

扎染品列表通过ForEach遍历this.data.tiedyes渲染。每条列表项采用左图标右信息的双栏布局。图标区域是一个44x44的圆形容器,内含emoji和两个装饰性圆点——大圆点带白色边框(模拟扎染的染料渗透效果),小圆点纯白色(模拟布面留白)。信息区域包含品名+价格行和标签行。i % 2 === 0实现斑马纹效果,提升长列表可读性。


十一、弹窗组件体系:四种模态对话框的实现

11.1 新增扎染弹窗AddTieDyeModal

@Component
struct AddTieDyeModal {
  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('¥58').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(18).constraintSize({ maxHeight: '78%' })
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

弹窗的构建模式遵循了一套统一的架构。外层Column铺满全屏,backgroundColor('#66000000')提供40%透明度的黑色遮罩,justifyContent(FlexAlign.Center)使弹窗内容垂直居中。内层Column宽度86%,提供白色背景和18的圆角值,constraintSize({ maxHeight: '78%' })限制最大高度防止内容溢出。

弹窗的内容布局采用"标题行 + 信息行组 + 按钮行"的结构。每个信息行都是左侧标签+右侧值的组合,使用cardAlt背景色和10的圆角,视觉上形成独立的卡片块。按钮行使用FlexAlign.End右对齐,取消按钮使用边框样式,确认按钮使用主色背景——这种"一弱一强"的按钮设计,引导用户优先选择确认操作。

11.2 其他弹窗组件

其他三个弹窗(EditTieDyeOrderModalDeleteClothModalDetailDyeModal)采用了与AddTieDyeModal一致的架构模式,差异仅在于内容:

EditTieDyeOrderModal:修改订单弹窗,展示客户名(民宿连锁)、数量变化(800→900件)和更新后金额(¥162000)。布局结构与新增弹窗完全一致,只是内容字段不同。

DeleteClothModal:删除确认弹窗,使用@Prop title: string接收要删除的布料名称。"确认撤下"按钮使用COLORS.danger(深红色)背景,与新增弹窗的主色按钮形成视觉对比——红色天然具有警示意味,提醒用户这是一个不可逆的删除操作。

DetailDyeModal:染液详情弹窗,使用@Prop name: string接收染液名称。展示染液工艺描述("蓼蓝叶入缸发酵七日,打靛成液...")、染材信息(蓼蓝 · 发酵缸)和匠人评级(甲级 · 掌缸廿载)。这是四个弹窗中信息量最大的一个,包含了叙述性文字和结构化信息的混合展示。


十二、关键技术深度解析

12.1 layoutWeight弹性布局机制

layoutWeight是ArkTS布局系统中处理弹性空间分配的核心属性。在本应用中,它被用于多个场景:Tab栏中每个Tab项设置layoutWeight(1)实现等分;列表项中左侧图标固定60宽度,右侧信息区域layoutWeight(1)占据剩余空间;图表进度条中,已填充部分layoutWeight(value)和未填充部分layoutWeight(100-value)实现按比例填充。

layoutWeight的工作原理是:在RowColumn中,所有固定尺寸的子元素先占据各自的空间,剩余空间按各子元素的layoutWeight值比例分配。这种机制使得UI能够在不同屏幕尺寸下自动适配——无论屏幕多宽,弹性元素都会自动填充剩余空间。

12.2 颜色透明度的拼接技巧

代码中大量使用color + 'XX'的方式来生成不同透明度的颜色变体。十六进制颜色值由6位RGB值和2位Alpha值组成(共8位),追加的2位值代表透明度:0D(约5%)、14(约8%)、33(约20%)、40(约25%)、55(约33%)、66(约40%)、80(约50%)、2E(约18%)。这种技巧使得同一个基础色可以生成多种透明度变体,无需定义大量颜色常量,极大地简化了色彩管理。

12.3 ForEach键值生成策略

ForEach(this.data.tiedyes, (p: TieDyeItem, i: number) => {
  // 渲染逻辑
}, (p: TieDyeItem, i: number) => 'td' + p.name + i)

ForEach的第三个参数是键值生成器。良好的键值策略对性能至关重要——当列表数据变化时,ArkTS框架通过键值判断哪些项需要创建、哪些需要销毁、哪些可以复用。这里使用'td' + p.name + i作为键值:前缀'td'标识数据类型(扎染品),p.name确保不同项有不同键值,i防止同名项的键值冲突。如果列表支持增删操作,建议使用更稳定的唯一标识(如UUID),以获得更好的差异化更新性能。

装饰器 数据流向 可变性 响应式 典型用途
@State 组件内部 可读可写 组件私有状态
@Prop 父→子(单向) 只读 传递简单数据给子组件
@ObjectLink 父↔子(双向引用) 可读(引用共享) 传递@Observed对象给子组件
@Observed 类级别标记 - 标记类为可观察

在本应用中,TieDyeData@Observed标记,主组件通过@State data: TieDyeData = new TieDyeData()持有实例,内容组件通过@ObjectLink data: TieDyeData接收引用——当主组件修改data的属性时,内容组件自动感知变化并刷新UI。弹窗组件通过@Prop title: string接收简单参数——@Prop创建值的拷贝,确保弹窗内的数据不受父组件后续修改的影响。

12.5 Scroll组件的滚动控制

所有内容组件都使用Scroll作为最外层容器,确保内容超出屏幕时可以滚动查看。关键属性配置包括:scrollable(ScrollDirection.Vertical)限定垂直滚动方向,scrollBar(BarState.Off)隐藏滚动条(保持视觉整洁),constraintSize({ maxHeight: '100%' })限制最大高度不超过父容器。内层Column同样设置constraintSize({ maxHeight: '100%' }),确保内容区域不会超出Tab栏。


十三、技术要点对比表

技术维度 实现方案 核心优势 注意事项
色彩体系 interface + const双层管理 编译期类型安全,集中维护 色值需与主题气质匹配
页面路由 @State curTab + if-else条件渲染 轻量高效,无需路由框架 Tab过多时if-else链过长
状态管理 @State + @Observed + @ObjectLink 数据自动同步,声明式刷新 需理解装饰器适用场景
数据传递 参数传递 + 回调函数 单向数据流,可追溯 回调需箭头函数绑定this
列表渲染 ForEach + keyGenerator 键值驱动差异化更新 键值需保证唯一性
图表实现 Column/Row + height/layoutWeight 零依赖,体积小 仅适合简单图表
弹窗管理 if条件渲染 + onClose回调 完全自定义样式 多弹窗需独立状态变量
动画系统 rotate/translate/scale + animation 声明式,代码简洁 复杂动画需animateTo
斑马纹 i % 2条件背景色 提升列表可读性 依赖索引参数
颜色透明度 color + 'XX'十六进制拼接 同色多变体,零常量 拼接值需为合法十六进制
标签组件 @Prop + 可复用struct 跨页面复用 属性变更需父组件驱动
滚动容器 Scroll + scrollBar(Off) 内容溢出可滚动 需限制最大高度

十四、总结与展望

14.1 架构设计总结

通过对扎染坊应用的完整代码剖析,我们可以提炼出以下几个在鸿蒙ArkTS开发中值得推广的架构实践:

第一,接口驱动的类型安全体系。 从TieDyePalette到五大业务实体接口,再到四种图表数据接口,全部采用interface先定义结构、再填充数据的模式。这种做法不仅获得了TypeScript的编译期类型安全保障,更使得数据结构成为前后端协作的"契约"——接口定义完成后,UI开发即可并行启动,无需等待数据实现。接口的另一个价值在于自文档化——通过阅读接口定义,开发者就能快速理解每种数据的结构和用途,无需翻阅注释或文档。

第二,三层状态管理模型。 应用的状态管理分为三个清晰的层次:@State管理组件私有状态(如curTabshowAddTieDye等),@Observed + @ObjectLink管理跨组件共享的响应式数据(如TieDyeData),@Prop管理父到子的只读数据传递。三层状态各司其职、互不越界——私有状态不外泄,共享数据自动同步,只读参数不可篡改。这种分层设计避免了"全局状态"的混乱,使得数据流向清晰可追踪。

第三,组件拆分的高内聚低耦合。 整个应用拆分为主组件(1个)、内容组件(5个)、弹窗组件(4个)和标签组件(1个),每个组件职责单一、接口清晰。内容组件通过@ObjectLink接收数据,通过回调函数与父组件通信,实现了数据与行为的完全解耦。弹窗组件通过@Prop接收参数,通过onClose回调通知关闭,形成了统一的交互模式。这种拆分策略使得每个组件都可以独立开发、独立测试、独立维护。

第四,纯函数的业务逻辑封装。 五个颜色工具函数都是无副作用的纯函数,它们不依赖任何外部状态,不修改任何全局变量。纯函数的优势在于:可测试(只需验证输入输出对应关系)、可缓存(相同输入可缓存结果)、可并行(无共享状态无竞态)、可组合(可像积木一样自由组装)。在复杂应用中,将业务逻辑封装为纯函数是控制代码复杂度的有效手段。

14.2 技术亮点回顾

从实现细节来看,这个应用有几个特别值得关注的技术亮点:

零依赖图表实现是最大的技术亮点。周销量柱状图、TOP6排行榜、染艺热度条形图、染法占比图——这些图表完全使用ArkTS的基础布局组件(ColumnRow)和属性绑定(heightlayoutWeight)实现,没有引入任何第三方图表库。这不仅减小了应用体积(省去了图表库的几十KB到几百KB代码),更重要的是避免了第三方库的版本兼容性风险和维护成本。柱状图通过ForEach遍历数据、height绑定数据值、alignItems(VerticalAlign.Bottom)底部对齐实现;进度条通过layoutWeight按比例分配填充和未填充部分实现——思路简洁但效果出众。

颜色透明度拼接技巧是另一个值得称道的细节。通过在6位颜色值后追加2位十六进制透明度值,实现了同一颜色的5-6种透明度变体。这种技巧在需要丰富色彩层次(如背景、边框、文字使用同色不同透明度)的场景下极其高效,无需定义大量颜色常量,一个基础色就能撑起一整套视觉体系。

emoji作为视觉标识是一种务实的选择。所有列表项都使用emoji作为图标,既节省了图片资源的加载开销(零网络请求、零本地存储),又提供了跨平台的视觉一致性。emoji是Unicode标准的一部分,所有现代设备都能正确渲染,且渲染效果通常优于自定义图标(色彩饱和度高、细节表现力强)。

装饰性元素与主题的视觉呼应是设计层面的亮点。头部装饰线使用不同大小和颜色的圆点模拟扎染染料斑点效果;列表项图标区域的圆点设计模拟了扎染布面的染料渗透与留白效果;卡片背景使用cardAlt浅色作为斑马纹的交替色,模拟了布面的纹理质感。这些细节虽然微小,却共同营造了与扎染主题高度契合的视觉氛围。

14.3 优化方向与未来展望

当然,这个应用也有一些可以进一步优化的方向:

数据层优化:当前所有数据都是静态定义在代码中的。在实际项目中,应该将数据抽离为独立的JSON文件或通过HTTP API获取,实现数据与逻辑的完全分离。可以引入数据仓库(Repository)模式,统一管理数据的获取、缓存和更新。

组件复用优化:四个弹窗组件的布局结构高度相似(外层遮罩+内层卡片+标题行+内容区+按钮行),可以抽取一个BaseModal基础弹窗组件,通过@BuilderParam插槽机制实现内容区域的自定义。这样可以消除大量的重复代码,提升可维护性。

动画系统优化:当前的动画较为简单(仅旋转、位移和缩放),可以引入animateTo显式动画API实现更丰富的过渡效果。例如,列表项的入场动画(从下方滑入并淡入)、弹窗的弹出动画(从中心缩放放大)、Tab切换时的页面过渡动画等。

性能优化ForEach的键值生成器目前使用name + index的组合。如果列表数据支持增删改查操作,建议使用更稳定的唯一标识字段(如id),以获得更好的差异化渲染性能。此外,对于超长列表(>100项),可以考虑使用LazyForEach进行懒加载,只渲染可视区域内的列表项。

无障碍优化:当前所有可交互元素都是Text组件,缺少accessibilityTextaccessibilityDescription等无障碍属性。在实际项目中,应该为每个可交互元素添加无障碍标签,确保视障用户也能通过读屏软件流畅使用应用。

国际化支持:当前所有文本都是硬编码的中文。如果应用需要面向国际市场,应该将所有文本抽离到资源文件中,通过$r('app.string.xxx')方式引用,支持多语言切换。

总而言之,这个扎染坊应用虽然是一个展示型Demo,但其架构设计之完整、代码组织之规范、交互细节之考究,已经具备了生产级应用的基础框架。它不仅是对非遗扎染文化的一次数字化致敬,更是一份优秀的鸿蒙ArkTS开发实践案例——从色彩体系的接口化定义到状态管理的分层设计,从纯函数的业务逻辑封装到零依赖的图表实现,每一处代码都体现了对工程质量和文化传承的双重追求。希望本文的逐段剖析,能够帮助开发者在鸿蒙开发的道路上少走弯路、多出精品。

安装DevEco Studio程序

在这里插入图片描述

选择目标安装目录:

在这里插入图片描述

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

在这里插入图片描述

新建一个空白模板:

在这里插入图片描述

设置API为24的模板项目:

在这里插入图片描述

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

在这里插入图片描述完整代码

interface TieDyePalette {
  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;
  indigoBlue: string;
  dyeWhite: string;
}

const COLORS: TieDyePalette = {
  primary: '#3E5C8A',
  primaryLight: '#6B85B0',
  primaryDark: '#27405F',
  accent: '#7A9BB8',
  accentLight: '#B4C8DC',
  bg: '#EEF2F6',
  cardBg: '#FFFFFF',
  cardAlt: '#E3EAF2',
  textPrimary: '#24324A',
  textSecondary: '#5E6E88',
  textHint: '#8FA0B8',
  border: '#D3DEEA',
  line: '#E9EFF5',
  success: '#5E8A6E',
  warning: '#C98A4E',
  danger: '#9E2E20',
  white: '#FFFFFF',
  indigoBlue: '#3E5C8A',
  dyeWhite: '#F4F0E4'
};

enum TieDyeTab {
  TIEDYE = 0,
  CLOTH = 1,
  DYE = 2,
  ORDER = 3,
  REVIEW = 4
}

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

const TAB_LIST: TabMeta[] = [
  { key: 'tiedye', icon: '🌀', label: '扎染', color: '#3E5C8A' },
  { key: 'cloth', icon: '🧵', label: '布料', color: '#6B85B0' },
  { key: 'dye', icon: '🧪', label: '染液', color: '#5E8A6E' },
  { key: 'order', icon: '📦', label: '订单', color: '#C98A4E' },
  { key: 'review', icon: '⭐', label: '客评', color: '#9E2E20' }
];

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

interface TieDyeItem {
  name: string;
  kind: string;
  cloth: string;
  price: number;
  emoji: string;
}

interface ClothItem {
  name: string;
  weave: string;
  level: number;
  place: string;
  emoji: string;
}

interface DyeItem {
  name: string;
  plant: string;
  level: number;
  tool: string;
  emoji: string;
}

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

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

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

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

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

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

const WEEK_SOLD: WeekTieDyeMeta[] = [
  { day: '周一', value: 26 },
  { day: '周二', value: 34 },
  { day: '周三', value: 42 },
  { day: '周四', value: 50 },
  { day: '周五', value: 58 },
  { day: '周六', value: 70 },
  { day: '周日', value: 63 }
];

const KIND_SHARE: KindShareMeta[] = [
  { name: '夹染', count: 5, color: '#3E5C8A' },
  { name: '扎染', count: 4, color: '#6B85B0' },
  { name: '蜡染', count: 3, color: '#5E8A6E' },
  { name: '型染', count: 2, color: '#C98A4E' }
];

const DYE_HOT: DyeHotMeta[] = [
  { name: '蓝靛发酵', heat: 97, color: '#3E5C8A' },
  { name: '扎花捆结', heat: 95, color: '#6B85B0' },
  { name: '浸染提色', heat: 93, color: '#5E8A6E' },
  { name: '氧化还原', heat: 92, color: '#7A9BB8' },
  { name: '漂洗固色', heat: 90, color: '#C98A4E' },
  { name: '拆线显花', heat: 88, color: '#9E2E20' }
];

const TIEDYE_TOP: TieDyeTopMeta[] = [
  { name: '蓝染方巾', sold: 97, color: '#3E5C8A' },
  { name: '扎染围巾', sold: 91, color: '#6B85B0' },
  { name: '夹染桌布', sold: 87, color: '#5E8A6E' },
  { name: '扎染连衣裙', sold: 82, color: '#7A9BB8' },
  { name: '蜡染壁挂', sold: 78, color: '#C98A4E' },
  { name: '扎染帆布包', sold: 75, color: '#9E2E20' }
];

@Observed
export class TieDyeData {
  tiedyes: TieDyeItem[] = [
    { name: '蓝染方巾', kind: '扎染', cloth: '棉布', price: 58, emoji: '🌀' },
    { name: '扎染围巾', kind: '扎染', cloth: '真丝', price: 168, emoji: '🧣' },
    { name: '夹染桌布', kind: '夹染', cloth: '麻布', price: 128, emoji: '🍽️' },
    { name: '扎染连衣裙', kind: '扎染', cloth: '棉麻', price: 268, emoji: '👗' },
    { name: '蜡染壁挂', kind: '蜡染', cloth: '棉布', price: 198, emoji: '🖼️' },
    { name: '扎染帆布包', kind: '扎染', cloth: '帆布', price: 88, emoji: '👜' },
    { name: '扎染手帕', kind: '扎染', cloth: '棉布', price: 38, emoji: '🧻' },
    { name: '型染门帘', kind: '型染', cloth: '棉布', price: 148, emoji: '🚪' },
    { name: '扎染抱枕', kind: '扎染', cloth: '棉麻', price: 78, emoji: '🛋️' },
    { name: '扎染床旗', kind: '扎染', cloth: '棉布', price: 228, emoji: '🛏️' },
    { name: '扎染头巾', kind: '扎染', cloth: '棉布', price: 48, emoji: '🎀' },
    { name: '扎染茶席', kind: '夹染', cloth: '亚麻', price: 108, emoji: '🍵' }
  ];

  cloths: ClothItem[] = [
    { name: '细棉布', weave: '平纹', level: 96, place: '江南', emoji: '🌾' },
    { name: '真丝绡', weave: '平绡', level: 95, place: '苏杭', emoji: '🦋' },
    { name: '麻布', weave: '平纹', level: 94, place: '湘西', emoji: '🌿' },
    { name: '棉麻混纺', weave: '斜纹', level: 93, place: '中原', emoji: '🧶' },
    { name: '帆布', weave: '重平', level: 92, place: '华北', emoji: '🛡️' },
    { name: '粗纱布', weave: '稀平', level: 91, place: '西北', emoji: '⛺' },
    { name: '双宫绸', weave: '缎纹', level: 95, place: '苏杭', emoji: '💠' },
    { name: '灯芯绒', weave: '绒纹', level: 90, place: '华东', emoji: '🕯️' },
    { name: '棉绸', weave: '斜纹', level: 92, place: '华中', emoji: '🎐' },
    { name: '亚麻', weave: '平纹', level: 93, place: '东北', emoji: '🍃' },
    { name: '泡泡纱', weave: '起泡纹', level: 89, place: '华南', emoji: '🫧' },
    { name: '丝棉', weave: '交织', level: 94, place: '苏杭', emoji: '☁️' }
  ];

  dyes: DyeItem[] = [
    { name: '蓝靛', plant: '蓼蓝', level: 96, tool: '发酵缸', emoji: '🔵' },
    { name: '靛青', plant: '马蓝', level: 95, tool: '打靛池', emoji: '💙' },
    { name: '苏木红', plant: '苏木', level: 93, tool: '熬煮锅', emoji: '❤️' },
    { name: '栀子黄', plant: '栀子', level: 94, tool: '染缸', emoji: '💛' },
    { name: '槐花绿', plant: '槐叶', level: 92, tool: '染池', emoji: '💚' },
    { name: '茜草绛', plant: '茜草', level: 93, tool: '染锅', emoji: '🧡' },
    { name: '五倍子墨', plant: '五倍子', level: 91, tool: '染池', emoji: '🖤' },
    { name: '核桃褐', plant: '核桃壳', level: 90, tool: '煮染锅', emoji: '🤎' },
    { name: '石榴皮黄', plant: '石榴皮', level: 92, tool: '染缸', emoji: '🍋' },
    { name: '紫草紫', plant: '紫草', level: 93, tool: '染锅', emoji: '💜' },
    { name: '莲蓬灰', plant: '莲蓬', level: 90, tool: '染池', emoji: '🩶' },
    { name: '竹叶青', plant: '竹叶', level: 91, tool: '染缸', emoji: '🎋' }
  ];

  orders: TieDyeOrderItem[] = [
    { name: '民宿连锁', region: '西南', count: 800, amount: 144000, emoji: '🏡' },
    { name: '非遗工坊', region: '云南', count: 600, amount: 120000, emoji: '🏛️' },
    { name: '汉服品牌', region: '华东', count: 500, amount: 130000, emoji: '👘' },
    { name: '文创买手店', region: '华东', count: 1200, amount: 96000, emoji: '🎁' },
    { name: '茶空间连锁', region: '华中', count: 400, amount: 88000, emoji: '🍵' },
    { name: '婚纱摄影馆', region: '华南', count: 300, amount: 84000, emoji: '📸' },
    { name: '海外生活馆', region: '海外', count: 350, amount: 105000, emoji: '🌏' },
    { name: '酒店布草部', region: '华北', count: 900, amount: 117000, emoji: '🏨' },
    { name: '亲子研学营', region: '华南', count: 500, amount: 65000, emoji: '🎒' },
    { name: '美术院校', region: '华中', count: 260, amount: 52000, emoji: '🎓' },
    { name: '景区文创店', region: '西南', count: 1000, amount: 70000, emoji: '🏞️' },
    { name: '直播选品团', region: '华东', count: 700, amount: 91000, emoji: '📺' }
  ];

  reviews: TieDyeReviewItem[] = [
    { 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: 4, date: '08-26', content: '孩子体验扎染课物料齐全,若附图样卡更贴心。', tag: '物料齐全', emoji: '🎒' },
    { name: '美院老师', score: 5, 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 getKindColor(kind: string): string {
  if (kind === '扎染') {
    return '#3E5C8A';
  } else if (kind === '夹染') {
    return '#6B85B0';
  } else if (kind === '蜡染') {
    return '#5E8A6E';
  }
  return '#C98A4E';
}

function getClothColor(level: number): string {
  if (level >= 95) {
    return '#3E5C8A';
  } else if (level >= 92) {
    return '#6B85B0';
  }
  return '#5E8A6E';
}

function getTieDyeColor(price: number): string {
  if (price >= 200) {
    return '#9E2E20';
  } else if (price >= 120) {
    return '#3E5C8A';
  } else if (price >= 60) {
    return '#6B85B0';
  }
  return '#5E8A6E';
}

function getOrderColor(amount: number): string {
  if (amount >= 120000) {
    return '#9E2E20';
  } else if (amount >= 90000) {
    return '#3E5C8A';
  } else if (amount >= 60000) {
    return '#6B85B0';
  }
  return '#5E8A6E';
}

function getReviewTagColor(tag: string): string {
  if (tag === '花色晕染' || tag === '沉稳雅致' || tag === '配色匀称' || tag === '纹样规整') {
    return '#3E5C8A';
  } else if (tag === '色牢度高' || tag === '扎纹别致' || tag === '独一无二') {
    return '#6B85B0';
  } else if (tag === '入镜质感' || tag === '手作温度' || tag === '绵软亲肤') {
    return '#5E8A6E';
  } else if (tag === '物料齐全' || tag === '走量飞快') {
    return '#C98A4E';
  }
  return '#9E2E20';
}

@Entry
@Component
struct TieDyeApp {
  @State curTab: number = 0;
  @State data: TieDyeData = new TieDyeData();
  @State showAddTieDye: boolean = false;
  @State showEditOrder: boolean = false;
  @State showDeleteCloth: boolean = false;
  @State showDetailDye: boolean = false;
  @State delClothName: string = '';
  @State detailDyeName: string = '';
  @State swirlRotate: boolean = false;
  @State dropFloat: 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('TieDye · 蓝靛晕染 一布千纹')
                .fontSize(10)
                .fontColor(COLORS.accentLight)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Row() {
              Text('🌀')
                .fontSize(20)
                .onClick(() => {
                  this.swirlRotate = !this.swirlRotate;
                })
                .rotate({ angle: this.swirlRotate ? 90 : 0 })
                .animation({ duration: 700, curve: Curve.EaseOut })
              Text('💧')
                .fontSize(16)
                .margin({ left: 10 })
                .onClick(() => {
                  this.dropFloat = !this.dropFloat;
                })
                .translate({ x: this.dropFloat ? 14 : -8, y: this.dropFloat ? -8 : 0 })
                .animation({ duration: 620, curve: Curve.EaseOut })
              Text('🧵')
                .fontSize(14)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(COLORS.accentLight + '2E')
            .borderRadius(24)
            .border({ width: 1, color: COLORS.accentLight + '80' })
          }
          .width('100%')

          Row() {
            ForEach(TIEDYE_COL, (r: number) => {
              Row() {
                if (r % 3 === 0) {
                  Column()
                    .width(12)
                    .height(12)
                    .backgroundColor(COLORS.indigoBlue)
                    .borderRadius(6)
                    .border({ width: 2, color: COLORS.dyeWhite })
                } else if (r % 3 === 1) {
                  Column()
                    .width(8)
                    .height(8)
                    .backgroundColor(COLORS.dyeWhite)
                    .borderRadius(4)
                } else {
                  Column()
                    .width(5)
                    .height(5)
                    .backgroundColor(COLORS.primaryLight)
                    .borderRadius(3)
                }
              }
              .width(14)
              .justifyContent(FlexAlign.Center)
            }, (r: number) => 'tiedye' + r)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ top: 12 })

          Row() {
            Column()
              .layoutWeight(1)
              .height(1)
              .backgroundColor(COLORS.accentLight + '66')
            Text('💙 青出于蓝 · 一染倾心 🌀')
              .fontSize(9)
              .fontColor(COLORS.accentLight)
              .margin({ left: 8, right: 8 })
            Column()
              .layoutWeight(1)
              .height(1)
              .backgroundColor(COLORS.accentLight + '66')
          }
          .width('100%')
          .margin({ top: 10 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.primaryDark)

        if (this.curTab === TieDyeTab.TIEDYE) {
          TieDyeContent({ data: this.data, onAdd: () => {
            this.showAddTieDye = true;
          } })
        } else if (this.curTab === TieDyeTab.CLOTH) {
          ClothContent({ data: this.data, onDel: (n: string) => {
            this.delClothName = n;
            this.showDeleteCloth = true;
          } })
        } else if (this.curTab === TieDyeTab.DYE) {
          DyeContent({ data: this.data, onDetail: (n: string) => {
            this.detailDyeName = n;
            this.showDetailDye = true;
          } })
        } else if (this.curTab === TieDyeTab.ORDER) {
          TieDyeOrderContent({ data: this.data, onEdit: () => {
            this.showEditOrder = true;
          } })
        } else {
          TieDyeReviewContent({ 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(14)
            .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.showAddTieDye) {
        AddTieDyeModal({
          onClose: () => {
            this.showAddTieDye = false;
          }
        })
      }
      if (this.showEditOrder) {
        EditTieDyeOrderModal({
          onClose: () => {
            this.showEditOrder = false;
          }
        })
      }
      if (this.showDeleteCloth) {
        DeleteClothModal({
          title: this.delClothName, onClose: () => {
            this.showDeleteCloth = false;
          }
        })
      }
      if (this.showDetailDye) {
        DetailDyeModal({
          name: this.detailDyeName, onClose: () => {
            this.showDetailDye = false;
          }
        })
      }
    }
  }
}

@Component
struct TieDyeTag {
  @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 TieDyeContent {
  @ObjectLink data: TieDyeData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Column()
              .width(10)
              .height(10)
              .backgroundColor(COLORS.indigoBlue)
              .borderRadius(5)
              .border({ width: 2, color: COLORS.dyeWhite })
            Column()
              .layoutWeight(1)
              .height(2)
              .backgroundColor(COLORS.primary + '55')
              .margin({ left: 4, right: 4 })
            Column()
              .width(6)
              .height(6)
              .backgroundColor(COLORS.dyeWhite)
              .borderRadius(3)
              .margin({ right: 4 })
            Column()
              .width(10)
              .height(10)
              .backgroundColor(COLORS.primaryLight)
              .borderRadius(5)
            Column()
              .layoutWeight(1)
              .height(2)
              .backgroundColor(COLORS.primary + '55')
              .margin({ left: 4, right: 4 })
            Column()
              .width(10)
              .height(10)
              .backgroundColor(COLORS.indigoBlue)
              .borderRadius(5)
              .border({ width: 2, color: COLORS.dyeWhite })
          }
          .width('100%')
          .margin({ bottom: 10 })
          Row() {
            Text('📈 本周扎染销量')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六70件')
              .fontSize(9)
              .fontColor(COLORS.primary)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_SOLD, (w: WeekTieDyeMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 63 ? COLORS.primary : COLORS.accent)
                Column()
                  .width(16)
                  .height(w.value)
                  .backgroundColor(w.value >= 63 ? COLORS.primary : COLORS.accent)
                  .borderRadius(8)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekTieDyeMeta) => w.day)
          }
          .width('100%')
          .height(150)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(20)
        .border({ width: 1, color: COLORS.primary + '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.tiedyes, (p: TieDyeItem, i: number) => {
            Row() {
              Column() {
                Column() {
                  Text(p.emoji)
                    .fontSize(18)
                    .textAlign(TextAlign.Center)
                }
                .width(44)
                .height(44)
                .justifyContent(FlexAlign.Center)
                .alignItems(HorizontalAlign.Center)
                .backgroundColor(getTieDyeColor(p.price) + '14')
                .borderRadius(22)
                .border({ width: 1, color: getTieDyeColor(p.price) + '66' })
                Row() {
                  Column()
                    .width(14)
                    .height(14)
                    .backgroundColor(getTieDyeColor(p.price))
                    .borderRadius(7)
                    .border({ width: 2, color: COLORS.dyeWhite })
                  Column()
                    .width(8)
                    .height(8)
                    .backgroundColor(COLORS.dyeWhite)
                    .borderRadius(4)
                    .margin({ left: 4 })
                }
                .width(30)
                .justifyContent(FlexAlign.Center)
              }
              .width(60)
              .padding({ top: 10, bottom: 6 })
              .backgroundColor(getTieDyeColor(p.price) + '0D')
              .borderRadius(22)
              .border({ width: 1, color: getTieDyeColor(p.price) + '33' })

              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text('¥' + p.price)
                    .fontSize(10)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(getTieDyeColor(p.price))
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                Row() {
                  TieDyeTag({ text: p.kind, color: getTieDyeColor(p.price) })
                  TieDyeTag({ text: p.cloth, 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(18)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: TieDyeItem, i: number) => 'td' + 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 ClothContent {
  @ObjectLink data: TieDyeData;
  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(TIEDYE_TOP, (t: TieDyeTopMeta) => {
            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: TieDyeTopMeta) => t.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(20)
        .border({ width: 1, color: COLORS.primary + '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.cloths, (c: ClothItem, i: number) => {
            Row() {
              Column() {
                Text(c.emoji)
                  .fontSize(18)
                  .textAlign(TextAlign.Center)
                Row() {
                  Column()
                    .width(10)
                    .height(10)
                    .backgroundColor(getClothColor(c.level))
                    .borderRadius(5)
                    .border({ width: 2, color: COLORS.dyeWhite })
                  Column()
                    .width(8)
                    .height(8)
                    .backgroundColor(COLORS.dyeWhite)
                    .borderRadius(4)
                    .margin({ left: 4 })
                }
                .width(28)
                .justifyContent(FlexAlign.Center)
                .margin({ top: 5 })
              }
              .width(56)
              .padding({ top: 10, bottom: 6 })
              .backgroundColor(getClothColor(c.level) + '0D')
              .borderRadius(16)
              .border({ width: 1, color: getClothColor(c.level) + '33' })

              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(c.weave + '织')
                    .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(getClothColor(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(18)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (c: ClothItem, i: number) => 'ct' + 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 DyeContent {
  @ObjectLink data: TieDyeData;
  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(DYE_HOT, (d: DyeHotMeta) => {
            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: DyeHotMeta) => d.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(20)
        .border({ width: 1, color: COLORS.primary + '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.dyes, (d: DyeItem, i: number) => {
            Row() {
              Column() {
                Text(d.emoji)
                  .fontSize(18)
                  .textAlign(TextAlign.Center)
                Column()
                  .width(14)
                  .height(14)
                  .backgroundColor(getClothColor(d.level))
                  .borderRadius(7)
                  .margin({ top: 5 })
              }
              .width(56)
              .padding({ top: 10, bottom: 6 })
              .backgroundColor(getClothColor(d.level) + '0D')
              .borderRadius(16)
              .border({ width: 1, color: getClothColor(d.level) + '33' })

              Column() {
                Row() {
                  Text(d.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text('工艺' + d.level)
                    .fontSize(9)
                    .fontColor(getClothColor(d.level))
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                Row() {
                  TieDyeTag({ text: d.plant + '染', color: COLORS.textSecondary })
                  Text(d.tool)
                    .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(d.name);
                })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(18)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (d: DyeItem, i: number) => 'dy' + d.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 TieDyeOrderContent {
  @ObjectLink data: TieDyeData;
  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(KIND_SHARE, (s: KindShareMeta) => {
            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: KindShareMeta) => s.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(20)
        .border({ width: 1, color: COLORS.primary + '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: TieDyeOrderItem, 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(18)
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (o: TieDyeOrderItem, 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 TieDyeReviewContent {
  @ObjectLink data: TieDyeData;

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

@Component
struct AddTieDyeModal {
  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('¥58')
            .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(18)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct EditTieDyeOrderModal {
  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('800 → 900 件')
            .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('¥162000')
            .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(18)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct DeleteClothModal {
  @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(18)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct DetailDyeModal {
  @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(18)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}


版权声明:本文为原创技术博文,转载请注明出处。如对文中技术细节有疑问或建议,欢迎在评论区交流讨论。

Logo

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

更多推荐