在HarmonyOS ArkTS的技术博物馆中,每一个@Entry入口组件都是一件镇馆之宝,它凝结着开发者对声明式UI范式的深刻理解。当我们站在展柜前凝视这些代码,看到的不仅是语法结构,更是一套完整的响应式编程思想——状态驱动渲染、组件化拆分、单向数据流——这些技术理念如同博物馆中的珍贵馆藏,值得每一位置身其中的参观者驻足品鉴。

本期策展以一款精品咖啡烘焙订阅工坊应用为馆藏核心,带领各位访客沿着"色彩馆藏—数据展品—状态管理—页面布展—弹窗交互"的参观动线,逐一拆解其技术脉络。我们将以博物馆导览员的视角,使用"展品"“馆藏”"溯源"等文博术语,为每一件代码展品撰写详尽的展签说明。

在这条参观路线上,您将看到@State如何像恒温恒湿系统一样维护状态的稳定性,ForEach如何像陈列师一样将数据展品有序排列,bindSheetbindContentCover如何像展柜玻璃一样隔而不离地呈现交互内容。让我们开启这场代码溯源之旅。

在这里插入图片描述

展览前言与技术背景

欢迎各位访客来到HarmonyOS ArkTS移动应用技术专题展。本次展览的馆藏核心,是一款面向精品咖啡爱好者的烘焙订阅工坊应用。该应用集成了生豆直采豆单、烘焙记录管理、冲煮方法库、订阅定制服务、庄园口碑排行和个人咖啡护照六大展品区域,通过双排Tab导航(上排"豆单/烘焙/冲煮",下排"订阅/庄园/我的")组织全部功能模块。作为基于HarmonyOS API 24开发的原生应用,它充分运用了ArkTS声明式UI的全部核心能力。

从技术策展的角度来看,这款应用的核心架构采用了"入口组件集中管理状态+子组件分散渲染视图"的设计模式。入口组件CoffeeMain173@Entry装饰器标记为页面入口,内部维护了7个@State状态变量,涵盖Tab切换状态(curTab)和6个弹窗控制状态。入口组件通过条件渲染(if/else if/else)在6个子页面之间切换,每个子页面都是独立的@Component,通过回调函数与入口组件进行数据交换。这种模式类似于博物馆的总控中心与各分展馆的关系——总控中心负责调度展览内容和控制灯光(状态管理),各分展馆则独立布置展品(UI渲染)。

在馆藏数据层面,应用定义了7组强类型数据接口(BeanItem173RoastBatch173BrewMethod173GearItem173EstateItem173SubHistory173OrderItem173),每组接口对应一个业务实体。这些接口通过模块级别的常量数组进行初始化,形成了一套完整的静态馆藏体系。此外还定义了FlavorItem173(风味标签)和OriginStat173(产地分布)两组辅助数据,用于数据可视化展示。5个纯函数(roastColor173scoreC173scoreBar173lossH173processC173)承担了"数据展品修复师"的角色,将原始数值转换为颜色值、百分比等UI可渲染的格式。
在这里插入图片描述

第一展区:色彩馆藏与数据建模

展品说明

interface ColorPalette173 {
  coffee: string;
  coffeeDeep: string;
  milk: string;
  milkDeep: string;
  caramel: string;
  bg: string;
  cardBg: string;
  textMain: string;
  textSub: string;
  textHint: string;
  border: string;
  danger: string;
  white: string;
  green: string;
  gold: string;
}

const COLORS173: ColorPalette173 = {
  coffee: '#4E342E',
  coffeeDeep: '#3E2723',
  milk: '#D7CCC8',
  milkDeep: '#BCAAA4',
  caramel: '#E64A19',
  bg: '#FBF7F4',
  cardBg: '#FFFFFF',
  textMain: '#3E2723',
  textSub: '#8D6E63',
  textHint: '#BCAAA4',
  border: '#EFE5E0',
  danger: '#D84315',
  white: '#FFFFFF',
  green: '#2E7D32',
  gold: '#E65100'
};

溯源解读

在这里插入图片描述

各位访客,现在我们面前的是色彩馆藏展柜。interface是TypeScript的核心语法,用于定义对象的类型契约。在HarmonyOS ArkTS中,接口被广泛用于定义数据模型,确保所有数据实例遵循统一的字段规范。ColorPalette173接口定义了15个字符串类型的色彩属性,从主色调coffee(咖啡棕#4E342E)到辅色调caramel(焦糖橙#E64A19),构成了整套应用视觉体系的基础色板。

const COLORS173: ColorPalette173 = {...}这行代码创建了一个实现该接口的常量对象。类型注解: ColorPalette173确保了编译器在编译阶段检查所有15个属性是否齐全。如果遗漏任何一个,TypeScript编译器会立即报错,这就像博物馆的藏品入馆登记——每一件藏品必须登记完整信息方可入藏。色彩值使用6位十六进制RGB格式(如#4E342E),这是Web端和移动端通用的颜色编码方式,ArkTS完全兼容。

继续参观数据模型展区,我们将看到多组业务实体的接口定义:

interface BeanItem173 {
  name: string;
  origin: string;
  icon: string;
  process: string;
  price: number;
  notes: string;
  score: number;
  tag: string;
}

interface RoastBatch173 {
  batch: string;
  bean: string;
  level: string;
  loss: number;
  date: string;
  score: number;
}

interface BrewMethod173 {
  name: string;
  icon: string;
  ratio: string;
  mins: number;
  grind: string;
  temp: string;
}

interface EstateItem173 {
  name: string;
  country: string;
  icon: string;
  score: number;
  alt: string;
  var: string;
}

interface FlavorItem173 {
  name: string;
  pct: number;
  color: string;
}

interface OriginStat173 {
  region: string;
  pct: number;
  color: string;
}

馆藏分析

在这里插入图片描述

上述六个接口分别定义了咖啡生豆、烘焙批次、冲煮方法、庄园信息、风味标签和产地分布的数据结构。BeanItem173是最丰富的展品,包含名称、产地、处理法、价格、风味描述、杯测评分和标签7个字段,每一个字段都承载着咖啡文化的信息。RoastBatch173记录了烘焙批次的编号、豆名、烘焙度、失重比、日期和评分,构成了烘焙档案的核心。BrewMethod173定义了冲煮方法的粉水比、时长、研磨度和水温,是冲煮参数的标准化记录。

在数据建模中,字段类型的选择体现了业务理解。score使用number类型而非string,是为了后续的数值比较(如score >= 90判断高分);notes使用string类型存储风味描述,因为它是展示文本而非计算数据。这种"计算用数字、展示用文本"的设计原则,是数据建模的基本素养。

FlavorItem173OriginStat173是两组特殊的数据接口,它们专门服务于数据可视化。FlavorItem173包含名称、百分比和颜色三个字段,用于风味雷达图或风味条的渲染。OriginStat173包含产区名称、百分比和颜色,用于产地分布的横向比例条。这种"数据+颜色"的接口设计,使得可视化组件可以直接使用接口数据渲染,无需额外的颜色计算逻辑。

第二展区:辅助函数——数据展品修复室

在这里插入图片描述

展品说明

function roastColor173(lv: string): string {
  if (lv === '浅烘') {
    return '#C58A5A';
  }
  if (lv === '中烘') {
    return '#8D6E63';
  }
  if (lv === '中深烘') {
    return '#5D4037';
  }
  return '#3E2723';
}

function scoreC173(s: number): string {
  if (s >= 90) {
    return COLORS173.gold;
  }
  if (s >= 85) {
    return COLORS173.caramel;
  }
  return COLORS173.textSub;
}

function scoreBar173(s: number): string {
  return (s - 70) + '%';
}

function lossH173(l: number): string {
  return (l * 5) + '%';
}

function processC173(p: string): string {
  if (p === '水洗') {
    return '#0277BD';
  }
  if (p === '日晒') {
    return '#E65100';
  }
  if (p === '厌氧日晒') {
    return '#8E24AA';
  }
  return '#2E7D32';
}

溯源解读

各位访客,现在我们进入了"数据展品修复室"。这里陈列着5个纯函数,它们的作用是将原始数值或文本转换为UI可以使用的颜色值或百分比字符串。在博物馆的语境中,这些函数就像是"展品修复师"——它们不创造新的展品(不产生副作用),而是对已有的展品进行加工处理,使其适合在展柜中展示。

roastColor173函数接收烘焙度字符串,返回对应的颜色值。浅烘返回浅棕色(#C58A5A),中烘返回中棕色(#8D6E63),中深烘返回深棕色(#5D4037),深烘返回最深棕色(#3E2723)。这套颜色梯度模拟了咖啡豆在不同烘焙度下的实际颜色变化,使色彩本身就成为了信息传递的载体。在展柜中,用户只需看一眼色块的颜色深浅,就能直观感知烘焙度的高低。

纯函数的核心特征是"相同输入永远产生相同输出",不依赖外部状态、不修改全局变量。在ArkTS声明式UI中,纯函数被广泛用于数据格式转换,因为其输出可预测、可缓存,框架可以安全地在渲染过程中调用而无需担心副作用。

scoreC173scoreBar173两个函数共同服务于杯测评分的展示。scoreC173将评分数值映射为颜色:90分以上使用金色(gold),85分以上使用焦糖橙(caramel),85分以下使用次级文本色(textSub)。scoreBar173将评分转换为进度条宽度百分比:(s - 70) + '%',即减去70分基数后取百分比。这种"减基数"的设计使得70分的评分对应0%的进度条,90分对应20%,92分对应22%,在视觉上更合理。

processC173函数将处理法映射为颜色:水洗返回蓝色(#0277BD,代表清洁),日晒返回橙色(#E65100,代表阳光),厌氧日晒返回紫色(#8E24AA,代表发酵),其他返回绿色(#2E7D32,代表自然)。这种"处理法→颜色"的映射,使每种处理法都有独特的视觉标识,在展柜中一目了然。

第三展区:入口组件——镇馆之宝

在这里插入图片描述

展品说明

@Entry
@Component
struct CoffeeMain173 {
  @State curTab: string = 'beans';
  @State showBuy: boolean = false;
  @State showSub: boolean = false;
  @State showRoastEdit: boolean = false;
  @State showCancelSub: boolean = false;
  @State showDetail: boolean = false;
  @State buyName: string = '耶加雪菲·科契尔';
  @State roastBatch: string = 'R-0825-01';
  @State detailName: string = '耶加雪菲·科契尔';
  @State detailIcon: string = '🫘';

  @Builder
  pageHeader() {
    Column() {
      Row() {
        Text('☕')
          .fontSize(22)
        Text('多多精品咖啡')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.white)
          .margin({ left: 6 })
        Text('烘焙坊直发')
          .fontSize(10)
          .fontColor(COLORS173.coffeeDeep)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(9)
          .backgroundColor('#EFB76A')
          .margin({ left: 10 })
        Text('')
          .layoutWeight(1)
        Text('🔍')
          .fontSize(17)
          .width(34)
          .height(34)
          .textAlign(TextAlign.Center)
          .borderRadius(17)
          .backgroundColor('#33FFFFFF')
          .margin({ right: 8 })
        Text('🛒')
          .fontSize(17)
          .width(34)
          .height(34)
          .textAlign(TextAlign.Center)
          .borderRadius(17)
          .backgroundColor('#33FFFFFF')
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Text('🔥 今日鲜烘 · 下单后 48h 内出炉')
          .fontSize(12)
          .fontColor('#FFFFFFD9')
        Text('')
          .layoutWeight(1)
        Text('满 199 减 30')
          .fontSize(11)
          .fontColor(COLORS173.caramel)
          .fontWeight(FontWeight.Bold)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .borderRadius(11)
          .backgroundColor('#FFFFFF')
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 10, bottom: 14 })
    .linearGradient({
      angle: 135,
      colors: [[COLORS173.coffee, 0.0], [COLORS173.coffeeDeep, 1.0]]
    })
  }

溯源解读

在这里插入图片描述

各位访客,现在我们来到的是镇馆之宝展柜——入口组件CoffeeMain173。首先映入眼帘的是@Entry装饰器。@Entry是ArkTS的入口装饰器,标记该组件为页面入口组件。在整个源文件中,只能有一个被@Entry装饰的struct,它代表整个页面的根节点,会被注册到路由系统中。@Entry@Component通常成对出现——@Component装饰器用于声明一个自定义组件,它告诉编译器这个struct是一个参与声明式UI渲染的UI组件。

进入组件内部,我们看到了10个@State状态变量。@State是ArkTS的状态管理装饰器,当被@State修饰的变量值发生变化时,框架会自动触发UI重新渲染,将最新数据反映到界面上。这10个变量可以分为三类:curTab管理当前选中的Tab页;showBuyshowSubshowRoastEditshowCancelSubshowDetail是5个弹窗显隐控制布尔值;buyNameroastBatchdetailNamedetailIcon是4个弹窗上下文数据变量。这种将所有状态集中在根组件管理的模式,就像博物馆总控中心统一管理各展馆的灯光开关——当任何一个开关(@State变量)变化时,对应的展馆(UI组件)会自动调整展示内容。

在ArkTS的响应式系统中,@State的更新是细粒度的——框架会精确追踪哪些UI部分依赖该变量,只更新受影响的部分,而非全量重渲染。这种"精准灯光调控"机制是ArkTS性能优势的核心来源之一。

@Builder装饰器出现在pageHeader方法上方。@Builder用于定义可复用的UI构建函数。被@Builder装饰的方法返回一段声明式UI代码,可以在组件的build方法中通过this.xxx()的方式调用。在这里,pageHeader方法构建了应用顶部头部区域,包含咖啡杯图标、品牌名称、"烘焙坊直发"标签、搜索和购物车图标。

在UI组件层面,Column是ArkUI提供的纵向线性布局容器,子元素按照垂直方向从上到下排列。Row是横向线性布局容器,子元素按照水平方向从左到右排列。Text组件用于显示文本内容,通过链式调用设置fontSize(字体大小)、fontColor(字体颜色)、fontWeight(字体粗细)、padding(内边距)、borderRadius(圆角)等样式。

linearGradient属性设置了组件的线性渐变背景,angle: 135指定135度对角渐变方向,colors: [[COLORS173.coffee, 0.0], [COLORS173.coffeeDeep, 1.0]]从咖啡棕渐变到深咖啡棕,营造出浓郁沉稳的视觉效果。这与农场应用的绿色渐变形成了鲜明对比——色彩馆藏的选择直接决定了应用的主题氛围。

第四展区:双排Tab导航——展厅导览系统

展品说明

@Builder
tabRows() {
  Column() {
    Row() {
      ForEach(TABS173, (t: TabItem173) => {
        if (t.row === 1) {
          Column() {
            Text(t.icon)
              .fontSize(16)
            Text(t.label)
              .fontSize(12)
              .fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.curTab === t.key ? COLORS173.caramel : COLORS173.textSub)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 8, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(this.curTab === t.key ? '#FBE9E7' : '#FFFFFF')
          .onClick(() => {
            this.curTab = t.key;
          })
        }
      }, (t: TabItem173) => t.key)
    }

    Row() {
      ForEach(TABS173, (t: TabItem173) => {
        if (t.row === 2) {
          Column() {
            Text(t.icon)
              .fontSize(16)
            Text(t.label)
              .fontSize(12)
              .fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.curTab === t.key ? COLORS173.caramel : COLORS173.textSub)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 8, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(this.curTab === t.key ? '#FBE9E7' : '#FFFFFF')
          .onClick(() => {
            this.curTab = t.key;
          })
        }
      }, (t: TabItem173) => t.key)
    }
  }
  .width('100%')
  .padding({ left: 12, right: 12 })
  .margin({ top: 10 })
}

@Builder
contentArea() {
  Column() {
    if (this.curTab === 'beans') {
      BeansTab173({
        onBuy: (n: string): void => {
          this.buyName = n;
          this.showBuy = true;
        },
        onDetail: (n: string, ic: string): void => {
          this.detailName = n;
          this.detailIcon = ic;
          this.showDetail = true;
        }
      })
    } else if (this.curTab === 'roast') {
      RoastTab173({
        onEdit: (n: string): void => {
          this.roastBatch = n;
          this.showRoastEdit = true;
        }
      })
    } else if (this.curTab === 'brew') {
      BrewTab173({
        onBuy: (n: string): void => {
          this.buyName = n;
          this.showBuy = true;
        }
      })
    } else if (this.curTab === 'sub') {
      SubTab173({
        onCustom: (): void => {
          this.showSub = true;
        },
        onCancelSub: (): void => {
          this.showCancelSub = true;
        }
      })
    } else if (this.curTab === 'estate') {
      EstateTab173({
        onDetail: (n: string, ic: string): void => {
          this.detailName = n;
          this.detailIcon = ic;
          this.showDetail = true;
        }
      })
    } else {
      MineTab173({
        onDetail: (n: string, ic: string): void => {
          this.detailName = n;
          this.detailIcon = ic;
          this.showDetail = true;
        }
      })
    }
  }
  .width('100%')
  .layoutWeight(1)
}

溯源解读

各位访客,这是本次展览中最具特色的"双排Tab导航"展品。与常规单排Tab不同,这款应用将6个Tab项分为上下两排:上排展示"豆单/烘焙/冲煮"三个核心功能,下排展示"订阅/庄园/我的"三个辅助功能。这种双排设计通过TabItem173接口中的row字段实现——row: 1的Tab在上排渲染,row: 2的Tab在下排渲染。

ForEach是ArkUI提供的循环渲染组件,用于根据数据列表生成UI组件。它接收三个参数:数据源数组、子项生成函数和键值生成函数。在这里,ForEach遍历TABS173数组,内部使用if (t.row === 1)if (t.row === 2)进行条件过滤,分别在上下两排渲染对应的Tab项。ForEach的键值生成函数返回t.key作为唯一标识,框架通过键值进行增量更新——当Tab选中状态变化时,只需更新相关Tab项的样式,而非重建整个Tab栏。

ForEach的键值生成函数(keyGenerator)是其性能优化的关键。就像博物馆的藏品编号系统,每件展品都有唯一编号,当展品调整位置时,工作人员只需对编号进行增删,而非全部重新编号。ForEach的键值机制同样如此——框架通过键值判断哪些项需要新增、删除或更新。

Tab项的选中态通过三元运算符实现:this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal。被选中的Tab文字加粗且使用焦糖橙色(COLORS173.caramel),背景色为浅橙色(#FBE9E7);未选中的Tab使用普通字重和次级文本色,背景为白色。当用户点击Tab时,onClick回调将curTab设置为该Tab的key值,由于curTab@State变量,框架自动触发UI重新渲染。

contentArea构建方法中,if/else if/else条件渲染根据curTab的值切换不同的页面组件。在ArkTS中,if/else用于在build方法或@Builder方法内部根据条件渲染不同的UI组件树。当curTab变化时,框架销毁旧条件分支的组件树,创建新条件分支的组件树。每个子页面组件通过参数接收回调函数:例如BeansTab173接收onBuyonDetail两个回调,当子组件内部的"下单烘焙"按钮被点击时,调用onBuy(b.name),触发父组件更新buyNameshowBuy状态,弹出购买弹窗。

第五展区:bindSheet与bindContentCover——交互展柜系统

展品说明

@Builder
buySheet() {
  BeanBuySheet173({
    beanName: this.buyName,
    onClose: (): void => {
      this.showBuy = false;
    }
  })
}

@Builder
subSheet() {
  SubSheet173({
    onClose: (): void => {
      this.showSub = false;
    }
  })
}

@Builder
roastEditSheet() {
  RoastEditSheet173({
    batchName: this.roastBatch,
    onClose: (): void => {
      this.showRoastEdit = false;
    }
  })
}

@Builder
cancelSubDialog() {
  CancelSubDialog173({
    onCancel: (): void => {
      this.showCancelSub = false;
    },
    onConfirm: (): void => {
      this.showCancelSub = false;
    }
  })
}

@Builder
detailDialog() {
  BeanDetailDialog173({
    beanName: this.detailName,
    beanIcon: this.detailIcon,
    onClose: (): void => {
      this.showDetail = false;
    }
  })
}

build() {
  Column() {
    this.pageHeader()
    this.tabRows()
    this.contentArea()
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS173.bg)
  .bindSheet($$this.showBuy, this.buySheet(), {
    height: 620,
    dragBar: true,
    showClose: true,
    backgroundColor: COLORS173.cardBg
  })
  .bindSheet($$this.showSub, this.subSheet(), {
    height: 560,
    dragBar: true,
    showClose: true,
    backgroundColor: COLORS173.cardBg
  })
  .bindSheet($$this.showRoastEdit, this.roastEditSheet(), {
    height: 500,
    dragBar: true,
    showClose: true,
    backgroundColor: COLORS173.cardBg
  })
  .bindContentCover($$this.showCancelSub, this.cancelSubDialog(), {
  })
  .bindContentCover($$this.showDetail, this.detailDialog(), {
  })
}

溯源解读

各位访客,这里展示的是应用的"交互展柜系统"——两种弹窗绑定机制。bindSheet是ArkUI提供的方法,用于将一个半模态底部弹窗绑定到组件上。它使用$$双向绑定语法将布尔类型的@State变量与弹窗的显隐状态绑定。$$语法是ArkTS特有的双向绑定标记,确保状态变量与UI状态之间保持同步。当绑定的变量为true时弹窗弹出,为false时弹窗收起。

bindSheet的配置参数中,height指定弹窗高度(如620像素),dragBar: true显示顶部拖拽条,showClose: true显示关闭按钮,backgroundColor设置弹窗背景色。本应用绑定了3个bindSheet弹窗:购买弹窗(620高度)、订阅弹窗(560高度)和烘焙编辑弹窗(500高度),分别用于不同的表单输入场景。半模态弹窗就像博物馆中可以拉开的抽屉式展柜——用户可以同时看到展柜下方的背景内容,交互压力较低。

bindSheetbindContentCover的展柜隐喻:bindSheet是"抽屉式展柜",从底部拉出,覆盖部分屏幕;bindContentCover是"全幅展柜",从中央展开,覆盖全屏。选择哪种展柜取决于展品的重要程度和用户注意力的聚焦需求。

bindContentCover是另一种弹窗绑定方法,用于全模态覆盖弹窗。与bindSheet的半模态不同,bindContentCover覆盖整个屏幕。本应用绑定了2个bindContentCover弹窗:退订确认弹窗和商品详情弹窗。退订确认属于需要用户明确决策的危险操作,全屏覆盖确保用户充分关注;商品详情包含大量信息(风味谱、冲煮建议、评价等),全屏展示提供更大的信息容量。

每个弹窗构建方法返回一个自定义组件实例,通过参数传递将上下文数据(如buyNamedetailName)和回调函数(如onClose)注入弹窗组件。这种设计使得弹窗组件完全独立于父组件,只需接收数据并通过回调通知结果,实现了组件的解耦。

第六展区:豆单页面——产地分布与生豆图鉴

展品说明

@Component
struct BeansTab173 {
  onBuy: (n: string) => void = () => {};
  onDetail: (n: string, ic: string) => void = () => {};

  @Builder
  originCard() {
    Column() {
      Row() {
        Text('🌍 在售豆仓产地分布')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
        Text('')
          .layoutWeight(1)
        Text('共 42 款生豆')
          .fontSize(10)
          .fontColor(COLORS173.textHint)
      }
      .width('100%')

      Row() {
        ForEach(ORIGINS173, (o: OriginStat173) => {
          Text('')
            .width(o.pct + '%')
            .height(16)
            .backgroundColor(o.color)
        }, (o: OriginStat173) => o.region)
      }
      .width('100%')
      .height(16)
      .borderRadius(8)
      .clip(true)
      .margin({ top: 10 })

      Row() {
        ForEach(ORIGINS173, (o: OriginStat173) => {
          Row() {
            Text('')
              .width(8)
              .height(8)
              .borderRadius(4)
              .backgroundColor(o.color)
            Text(o.region + ' ' + o.pct + '%')
              .fontSize(10)
              .fontColor(COLORS173.textSub)
              .margin({ left: 4 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Start)
        }, (o: OriginStat173) => 'lg' + o.region)
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS173.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }

  build() {
    Scroll() {
      Column() {
        this.originCard()

        Row() {
          Text('☕ 生豆直采豆单')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('按杯测分排序')
            .fontSize(11)
            .fontColor(COLORS173.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(BEANS173, (b: BeanItem173) => {
            Row() {
              Text(b.icon)
                .fontSize(26)
                .width(60)
                .height(60)
                .textAlign(TextAlign.Center)
                .borderRadius(14)
                .backgroundColor('#F5EDE7')

              Column() {
                Row() {
                  Text(b.name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS173.textMain)
                  Text(b.process)
                    .fontSize(9)
                    .fontColor(COLORS173.white)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(7)
                    .backgroundColor(processC173(b.process))
                    .margin({ left: 6 })
                }

                Text(b.origin + ' · ' + b.notes)
                  .fontSize(11)
                  .fontColor(COLORS173.textSub)
                  .margin({ top: 4 })

                Row() {
                  Text('杯测 ' + b.score)
                    .fontSize(11)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(scoreC173(b.score))
                  Text(b.tag)
                    .fontSize(9)
                    .fontColor(COLORS173.caramel)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(7)
                    .backgroundColor('#FBE9E7')
                    .margin({ left: 8 })
                  Text('')
                    .layoutWeight(1)
                  Text('¥' + b.price + '/200g')
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS173.caramel)
                  Text('下单烘焙')
                    .fontSize(11)
                    .fontColor(COLORS173.white)
                    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                    .borderRadius(11)
                    .backgroundColor(COLORS173.coffee)
                    .margin({ left: 10 })
                    .onClick(() => {
                      this.onBuy(b.name);
                    })
                }
                .width('100%')
                .margin({ top: 6 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS173.cardBg)
            .borderRadius(14)
            .margin({ top: 10 })
            .onClick(() => {
              this.onDetail(b.name, b.icon);
            })
          }, (b: BeanItem173) => b.name)
        }
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

溯源解读

各位访客,现在我们进入的是豆单展区——这里陈列着12款精选生豆,每款都有完整的产地、处理法、风味描述和杯测评分。BeansTab173是一个被@Component装饰的自定义组件,定义了两个成员变量onBuyonDetail作为回调函数接口。父组件在引用该子组件时注入具体的回调实现,子组件在按钮点击时调用这些回调,实现数据向父组件的回传。

产地分布卡片originCard是本展区最具技术特色的展品。横向比例条通过ForEach渲染ORIGINS173数组的4个产地数据,每个Text('')空文本组件的宽度设置为o.pct + '%'(如"38%"),背景色为该产地对应的颜色。4个色块紧密排列在同一Row容器中,通过clip(true)裁剪为圆角,形成完整的比例条。下方的图例区域同样使用ForEach渲染,每个图例项包含一个小色块和产地名称百分比文本。这种"比例条+图例"的数据可视化模式,是移动端展示分布数据的经典方案。

在ArkTS中,Text('')空文本组件是最轻量的"色块"实现方式——它不渲染任何文字内容,只保留尺寸和背景属性,可用于构建进度条、比例条、色块标记等视觉元素。这种"以Text代色块"的技巧在声明式UI中被广泛使用。

生豆列表中,每个豆子卡片的处理法标签通过processC173(b.process)函数获取颜色——水洗为蓝色、日晒为橙色、厌氧日晒为紫色等。这种"处理法→颜色"的映射使每张卡片在视觉上都能快速区分处理工艺。杯测评分的文字颜色通过scoreC173(b.score)函数获取——90分以上金色、85分以上焦糖橙、85分以下次级灰色。两种颜色函数的组合使用,使每张豆子卡片的信息层次分明。

maxLinestextOverflow属性在豆子卡片中没有直接使用,但b.origin + ' · ' + b.notes这种字符串拼接展示了ArkTS中处理复合文本的方式。通过·分隔符连接产地和风味描述,在单个Text组件中展示多段信息,减少了组件嵌套层级。

第七展区:烘焙记录与失重比柱状图

展品说明

@Component
struct RoastTab173 {
  onEdit: (n: string) => void = () => {};

  @Builder
  levelCard() {
    Column() {
      Text('🔥 烘焙度谱系')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')

      Row() {
        ForEach(['浅烘', '中烘', '中深烘', '深烘'], (lv: string) => {
          Column() {
            Text('')
              .width('100%')
              .height(34)
              .borderRadius(10)
              .backgroundColor(roastColor173(lv))
            Text(lv)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS173.textMain)
              .margin({ top: 5 })
            Text('花果酸香')
              .fontSize(9)
              .fontColor(COLORS173.textSub)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .margin({ left: 4, right: 4 })
        }, (lv: string) => lv)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS173.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }

  @Builder
  lossCard() {
    Column() {
      Row() {
        Text('📉 近期批次失重比')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
        Text('')
          .layoutWeight(1)
        Text('失重越高越深烘')
          .fontSize(10)
          .fontColor(COLORS173.textHint)
      }
      .width('100%')

      Row() {
        ForEach(ROASTS173, (r: RoastBatch173, idx: number) => {
          if (idx < 6) {
            Column() {
              Column() {
                Text('')
                  .width('100%')
                  .height(lossH173(r.loss))
                  .borderRadius({ topLeft: 5, topRight: 5 })
                  .backgroundColor(roastColor173(r.level))
              }
              .width('70%')
              .height(100)
              .justifyContent(FlexAlign.End)
              .backgroundColor('#F5EDE7')
              .borderRadius({ topLeft: 5, topRight: 5 })
              .clip(true)

              Text(r.loss + '%')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.textMain)
                .margin({ top: 4 })
              Text(r.batch.slice(3, 8))
                .fontSize(9)
                .fontColor(COLORS173.textSub)
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
        }, (r: RoastBatch173) => 'loss' + r.batch)
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS173.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }
}

溯源解读

各位访客,这是烘焙展区中最引人注目的两件数据可视化展品。烘焙度谱系卡片levelCard通过ForEach渲染4个烘焙度色块,每个色块是一个Text('')空文本组件,宽度100%高度34像素,背景色通过roastColor173(lv)函数获取。4个色块从浅到深排列,配合下方的烘焙度名称和风味描述,形成了一套完整的"烘焙度色谱"。

失重比柱状图卡片lossCard是更复杂的可视化展品。每个柱子由外层Column(宽70%高100像素,作为柱状图容器,背景色为浅咖啡色#F5EDE7)和内层Column(包含一个Text('')空文本,高度通过lossH173(r.loss)计算)组成。justifyContent(FlexAlign.End)使内层柱子从底部向上生长,模拟柱状图的视觉效果。borderRadius({ topLeft: 5, topRight: 5 })只设置顶部圆角,clip(true)裁剪超出外层容器圆角的部分。

lossH173函数将失重比数值乘以5转换为百分比高度:12%失重 → 60%高度,19%失重 → 95%高度。这种"线性映射"的函数设计简洁有效,将数值差异直接转化为视觉高度差异。柱子的颜色通过roastColor173(r.level)获取,使每个柱子不仅高度不同,颜色也不同——深烘的柱子颜色更深、高度更高,双重信息传达。

r.batch.slice(3, 8)使用JavaScript的slice方法截取批次号的一部分作为标签。批次号"R-0825-01"截取后得到"0825-0",这是一种简化标签的方式。在ArkTS中,所有标准的JavaScript字符串方法都可以使用,这体现了ArkTS与TypeScript/JavaScript的完全兼容性。

第八展区:冲煮方法横滑卡片

展品说明

@Component
struct BrewTab173 {
  onBuy: (n: string) => void = () => {};

  @Builder
  methodCard() {
    Scroll() {
      Row() {
        ForEach(BREWS173, (m: BrewMethod173) => {
          Column() {
            Text(m.icon)
              .fontSize(32)
            Text(m.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS173.white)
              .margin({ top: 6 })
            Text('粉水 ' + m.ratio + ' · ' + m.grind)
              .fontSize(10)
              .fontColor('#FFFFFFB3')
              .margin({ top: 4 })
            Text(m.temp + ' · ' + (m.mins >= 60 ? (Math.round(m.mins / 60) + '小时') : (m.mins + '分钟')))
              .fontSize(10)
              .fontColor('#EFB76A')
              .margin({ top: 3 })
          }
          .width(170)
          .height(130)
          .padding(12)
          .alignItems(HorizontalAlign.Start)
          .borderRadius(16)
          .linearGradient({
            angle: 135,
            colors: [[COLORS173.caramel, 0.0], [COLORS173.coffeeDeep, 1.0]]
          })
          .margin({ right: 10 })
        }, (m: BrewMethod173) => m.name)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
  }
}

溯源解读

各位访客,这里展示的是冲煮方法库的横向滚动卡片。Scroll是ArkUI提供的可滚动容器组件,通过scrollable(ScrollDirection.Horizontal)设置水平滚动方向,scrollBar(BarState.Off)隐藏滚动条。ForEach渲染6种冲煮方法(手冲V60、法压壶、冷萃、摩卡壶、爱乐压、虹吸壶),每张卡片宽度固定170像素高度130像素,通过margin({ right: 10 })设置卡片间距。

每张卡片使用了linearGradient从焦糖橙到深咖啡棕的对角线渐变背景,与白色和金黄色的文字形成高对比度。在时间显示上,代码使用了条件表达式和数学运算:m.mins >= 60 ? (Math.round(m.mins / 60) + '小时') : (m.mins + '分钟')。冷萃的mins值为720(12小时),通过Math.round(720 / 60)计算得到12,显示为"12小时";手冲的mins值为3,直接显示"3分钟"。

Math.round是JavaScript内置的数学函数,在ArkTS中完全可用。这种"数值→格式化文本"的转换逻辑在声明式UI中非常常见,它使得数据可以以更友好的方式呈现给用户。在ArkTS中,可以在Text的内容表达式中使用任意JavaScript表达式,包括条件运算符、数学函数和字符串拼接。

alignItems(HorizontalAlign.Start)使卡片内的子元素从左侧对齐,这对于固定宽度的卡片来说更符合阅读习惯。HorizontalAlign是ArkUI定义的水平对齐枚举,包含Start(左对齐)、Center(居中对齐)、End(右对齐)三个值。

展品流程图

渲染错误: Mermaid 渲染失败: Parse error on line 3: ...eMain173] B --> C[初始化10个@State状态变量] ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

第九展区:庄园口碑榜——排名与评分条

展品说明

@Component
struct EstateTab173 {
  onDetail: (n: string, ic: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🏆 庄园口碑榜')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('COE 榜单同步')
            .fontSize(11)
            .fontColor(COLORS173.caramel)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          ForEach(ESTATES173, (e: EstateItem173, idx: number) => {
            if (idx < 5) {
              Row() {
                Text((idx + 1) + '')
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(idx === 0 ? '#EFB76A' : COLORS173.textHint)
                  .width(28)

                Text(e.icon)
                  .fontSize(20)

                Column() {
                  Text(e.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS173.textMain)
                  Text(e.country + ' · ' + e.var)
                    .fontSize(10)
                    .fontColor(COLORS173.textSub)
                    .margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })

                Column() {
                  Text(e.score + '分')
                    .fontSize(12)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(scoreC173(e.score))
                  Row() {
                    Text('')
                      .width(scoreBar173(e.score))
                      .height(5)
                      .borderRadius(3)
                      .backgroundColor(scoreC173(e.score))
                  }
                  .width(52)
                  .height(5)
                  .borderRadius(3)
                  .backgroundColor('#F5EDE7')
                  .justifyContent(FlexAlign.Start)
                  .clip(true)
                  .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%')
              .padding({ top: 11, bottom: 11 })
              .backgroundColor(COLORS173.cardBg)
              .borderRadius(12)
              .margin({ top: 8 })
              .onClick(() => {
                this.onDetail(e.name, e.icon);
              })
            }
          }, (e: EstateItem173) => 'rank' + e.name)
        }
        .width('100%')

        Column() {
          ForEach(ESTATES173, (e: EstateItem173) => {
            Column() {
              Row() {
                Text(e.icon)
                  .fontSize(26)
                Text('')
                  .layoutWeight(1)
                Text(e.alt)
                  .fontSize(9)
                  .fontColor(COLORS173.white)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor(COLORS173.coffee)
              }
              .width('100%')

              Text(e.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.textMain)
                .width('100%')
                .margin({ top: 6 })

              Text(e.country + ' · ' + e.var)
                .fontSize(10)
                .fontColor(COLORS173.textSub)
                .width('100%')
                .margin({ top: 3 })

              Text('杯测 ' + e.score)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(scoreC173(e.score))
                .width('100%')
                .margin({ top: 5 })
            }
            .padding(12)
            .backgroundColor(COLORS173.cardBg)
            .borderRadius(14)
            .alignItems(HorizontalAlign.Start)
            .onClick(() => {
              this.onDetail(e.name, e.icon);
            })
          }, (e: EstateItem173) => 'grid' + e.name)
        }
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

溯源解读

各位访客,庄园口碑榜展区由两部分组成:上方的Top5排行榜和下方的全部庄园网格。排行榜使用ForEach渲染前5名庄园(if (idx < 5)条件过滤),每行包含排名编号、庄园图标、庄园名称/产地/品种信息、评分和进度条。排名第一的编号使用金黄色(#EFB76A),其他排名使用次级文本色,这种"冠军金色"的视觉处理增强了排名的荣誉感。

评分进度条使用了与豆单页相同的"空Text色块"技术:外层Row(宽52高5背景色为浅咖啡色)内嵌一个Text('')空文本,宽度通过scoreBar173(e.score)计算(如92分对应22%),背景色通过scoreC173(e.score)获取(90分以上金色)。这种进度条与前文的失重比柱状图虽然都是"外层容器+内层色块"的结构,但方向不同——进度条是水平向右填充,柱状图是垂直向上生长。

在ArkTS中,同一个技术模式(空Text色块)可以应用于不同的可视化场景。进度条是"左对齐水平填充",柱状图是"底对齐垂直增长",它们的区别仅在于外层容器的justifyContent属性值——FlexAlign.Start(左侧/顶部对齐)vs FlexAlign.End(右侧/底部对齐)。这种"一个模式多种用途"的设计体现了声明式UI的灵活性。

ForEach的键值生成函数在排行榜和网格中分别使用了'rank' + e.name'grid' + e.name前缀。虽然两个ForEach的数据源相同(都是ESTATES173),但通过添加不同的前缀,确保了键值的唯一性,避免了框架在diff计算时的冲突。这种"同数据不同前缀"的键值设计,是处理同一数据源多次渲染的最佳实践。

第十展区:订阅页面——历史豆单与退订

展品说明

@Component
struct SubTab173 {
  onCustom: () => void = () => {};
  onCancelSub: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('☕ 探索盒 · 第 15 期')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.white)
              Text('每月 2 款庄园微批次 · 100g×2')
                .fontSize(11)
                .fontColor('#EFB76A')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('¥89/期')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#EFB76A')
          }
          .width('100%')

          Row() {
            Text('下次发货:09-01 · 顺丰冷链')
              .fontSize(11)
              .fontColor('#FFFFFFB3')
            Text('')
              .layoutWeight(1)
            Text('定制口味')
              .fontSize(12)
              .fontColor(COLORS173.coffeeDeep)
              .fontWeight(FontWeight.Bold)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor('#EFB76A')
              .onClick(() => {
                this.onCustom();
              })
          }
          .width('100%')
          .margin({ top: 12 })
        }
        .width('100%')
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [[COLORS173.coffee, 0.0], [COLORS173.caramel, 1.0]]
        })
        .margin({ top: 12 })

        Column() {
          ForEach(SUB_HISTORY173, (h: SubHistory173) => {
            Row() {
              Text('📦')
                .fontSize(20)

              Column() {
                Text(h.box)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.textMain)
                Text(h.beans)
                  .fontSize(11)
                  .fontColor(COLORS173.textSub)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })

              Column() {
                Text(h.status)
                  .fontSize(11)
                  .fontColor(COLORS173.green)
                  .fontWeight(FontWeight.Medium)
                Text(h.date)
                  .fontSize(9)
                  .fontColor(COLORS173.textHint)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS173.cardBg)
            .borderRadius(12)
            .margin({ top: 8 })
          }, (h: SubHistory173) => h.box)
        }
        .width('100%')
        .margin({ top: 2 })

        Row() {
          Column() {
            Text('暂停或终止订阅')
              .fontSize(13)
              .fontColor(COLORS173.textSub)
            Text('已购期次不受影响')
              .fontSize(10)
              .fontColor(COLORS173.textHint)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('退订')
            .fontSize(12)
            .fontColor(COLORS173.danger)
            .padding({ left: 18, right: 18, top: 8, bottom: 8 })
            .borderRadius(14)
            .border({ width: 1, color: COLORS173.danger })
            .onClick(() => {
              this.onCancelSub();
            })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS173.cardBg)
        .borderRadius(14)
        .margin({ top: 14 })
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

溯源解读

各位访客,订阅展区展示了一个完整的咖啡订阅服务体验。顶部卡片使用了从咖啡棕到焦糖橙的渐变背景,包含了订阅期号、内容描述、价格和下次发货信息。"定制口味"按钮使用金黄色背景(#EFB76A),与渐变背景形成对比,吸引用户点击。点击后调用this.onCustom()回调,触发父组件弹出SubSheet173订阅定制弹窗。

历史豆单列表使用ForEach渲染5期订阅记录,每条记录包含盒子信息、豆子内容、状态和送达日期。h.status使用绿色文字显示"已冲煮"状态,h.date使用次级灰色显示日期。每条记录的左右信息分别通过.alignItems(HorizontalAlign.Start).alignItems(HorizontalAlign.End)实现左右对齐——左侧是主信息(盒子名和豆子内容),右侧是状态信息(状态和日期)。这种"左右分栏"的信息布局在列表项中非常常见。

在信息密集的列表项中,使用.alignItems(HorizontalAlign.Start).alignItems(HorizontalAlign.End)创建左右分栏是ArkTS的常用布局模式。左侧承载主要信息,右侧承载状态或时间信息,通过layoutWeight(1)在中间创建弹性间隔,实现信息的自然分流。

退订区域的设计体现了"弱化危险操作"的交互原则。"退订"按钮使用轮廓样式(border边框+透明背景)而非实色填充,视觉上弱于页面上的其他操作按钮。这种设计减少了用户误退订的概率,同时仍然提供了退订入口。点击后调用this.onCancelSub()回调,触发父组件弹出CancelSubDialog173全屏确认弹窗。

第十一展区:购买弹窗与订阅定制弹窗

展品说明

@Component
struct BeanBuySheet173 {
  beanName: string = '';
  onClose: () => void = () => {};
  @State selWeight: string = '200g';
  @State selRoast: string = '中烘';
  @State grind: boolean = false;
  @State count: number = 1;

  build() {
    Column() {
      Row() {
        Text('🛒 下单烘焙')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
        Text('')
          .layoutWeight(1)
        Text('出炉即发')
          .fontSize(10)
          .fontColor(COLORS173.white)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(8)
          .backgroundColor(COLORS173.caramel)
      }
      .width('100%')

      Row() {
        Text('☕')
          .fontSize(30)
        Column() {
          Text(this.beanName)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('下单后 48h 内烘焙 · 顺丰包邮')
            .fontSize(10)
            .fontColor(COLORS173.textSub)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#F5EDE7')
      .borderRadius(14)
      .margin({ top: 12 })

      Text('克重规格')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 14 })
      Row() {
        ForEach(['100g', '200g', '454g', '1kg'], (w: string) => {
          Text(w)
            .fontSize(12)
            .fontColor(this.selWeight === w ? COLORS173.white : COLORS173.textSub)
            .fontWeight(this.selWeight === w ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selWeight === w ? COLORS173.coffee : '#F5EDE7')
            .margin({ right: 8 })
            .onClick(() => {
              this.selWeight = w;
            })
        }, (w: string) => w)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Text('烘焙度')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 14 })
      Row() {
        ForEach(['浅烘', '中烘', '中深烘', '深烘'], (r: string) => {
          Text(r)
            .fontSize(12)
            .fontColor(this.selRoast === r ? COLORS173.white : COLORS173.textMain)
            .fontWeight(this.selRoast === r ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selRoast === r ? roastColor173(r) : '#F5EDE7')
            .margin({ right: 8 })
            .onClick(() => {
              this.selRoast = r;
            })
        }, (r: string) => r)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Row() {
        Column() {
          Text(this.grind ? '磨粉发货' : '整豆发货')
            .fontSize(13)
            .fontWeight(FontWeight.Medium)
            .fontColor(COLORS173.textMain)
          Text(this.grind ? '按所选器具研磨' : '到手自磨风味更佳')
            .fontSize(10)
            .fontColor(COLORS173.textSub)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text(this.grind ? '已开启' : '未开启')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.grind ? COLORS173.caramel : COLORS173.textHint)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .borderRadius(12)
          .backgroundColor(this.grind ? '#FBE9E7' : '#F5EDE7')
          .onClick(() => {
            this.grind = !this.grind;
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#FFFBF7')
      .borderRadius(12)
      .margin({ top: 14 })

      Row() {
        Column() {
          Text('合计')
            .fontSize(10)
            .fontColor(COLORS173.textSub)
          Text('¥' + (this.count * 68))
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.caramel)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)

        Text('')
          .layoutWeight(1)

        Text('去结算')
          .fontSize(15)
          .fontColor(COLORS173.white)
          .fontWeight(FontWeight.Bold)
          .padding({ left: 34, right: 34, top: 11, bottom: 11 })
          .borderRadius(22)
          .backgroundColor(COLORS173.caramel)
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')
      .margin({ top: 14 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 18 })
    .backgroundColor(COLORS173.cardBg)
  }
}

溯源解读

各位访客,BeanBuySheet173是购买烘焙的底部弹窗组件,通过bindSheet绑定显示。它接收beanName参数和onClose回调,内部维护4个@State状态变量:selWeight(克重)、selRoast(烘焙度)、grind(是否磨粉)和count(数量)。这些状态变量的变化只影响弹窗内部的UI更新,不会影响外部页面。

烘焙度选项的选择器使用了roastColor173(r)函数动态设置选中项的背景色。这意味着选中"浅烘"时按钮背景为浅棕色(#C58A5A),选中"深烘"时为最深棕色(#3E2723)。这种"选中色=烘焙色"的设计非常巧妙——用户选择烘焙度时,按钮本身的颜色就直观展示了该烘焙度的视觉效果,形成了一种"所见即所得"的交互体验。

这种"选项颜色映射业务含义"的设计在ArkTS中非常容易实现——只需在三元运算符中调用颜色函数即可。关键在于颜色函数的设计要有明确的业务语义,使得每种颜色都能承载信息。在本例中,烘焙度颜色直接来自roastColor173函数,该函数也用于烘焙度谱系卡片和失重比柱状图,确保了全局颜色一致性。

磨粉开关使用了"按钮式开关"设计:开启时显示"已开启"和焦糖橙背景,关闭时显示"未开启"和浅灰色背景。与滑块式开关不同,按钮式开关更节省垂直空间,适合在弹窗等紧凑布局中使用。两种开关的本质都是通过@State布尔变量控制样式和文字,点击时执行取反操作this.grind = !this.grind

合计金额的计算使用了表达式this.count * 68,直接在Text组件的内容中嵌入JavaScript表达式。ArkTS的Text组件支持在花括号{}中使用任意返回字符串的表达式,包括数学运算、条件运算和函数调用。这种"表达式嵌入文本"的能力使得动态数值的展示非常简洁。

第十二展区:商品详情全屏弹窗——风味谱与冲煮建议

展品说明

@Component
struct BeanDetailDialog173 {
  beanName: string = '';
  beanIcon: string = '';
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text(this.beanIcon)
            .fontSize(44)
          Text('')
            .layoutWeight(1)
          Text('杯测 86 分')
            .fontSize(12)
            .fontColor(COLORS173.white)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .borderRadius(12)
            .backgroundColor('#33FFFFFF')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 20, bottom: 20 })
        .linearGradient({
          angle: 135,
          colors: [[COLORS173.caramel, 0.0], [COLORS173.coffeeDeep, 1.0]]
        })

        Column() {
          Text(this.beanName)
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
            .width('100%')

          Row() {
            Text('水洗处理')
              .fontSize(11)
              .fontColor('#0277BD')
              .padding({ left: 7, right: 7, top: 3, bottom: 3 })
              .borderRadius(8)
              .backgroundColor('#E1F5FE')
            Text('海拔 1900m')
              .fontSize(11)
              .fontColor(COLORS173.textSub)
              .margin({ left: 10 })
            Text('')
              .layoutWeight(1)
            Text('¥68/200g')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS173.caramel)
          }
          .width('100%')
          .margin({ top: 8 })

          Text('风味谱')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
            .width('100%')
            .margin({ top: 12 })

          Column() {
            ForEach(BEAN_FLAVORS173, (f: FlavorItem173) => {
              Row() {
                Text(f.name)
                  .fontSize(12)
                  .fontColor(COLORS173.textMain)
                  .width(74)
                Row() {
                  Text('')
                    .width(f.pct + '%')
                    .height(10)
                    .borderRadius(5)
                    .backgroundColor(f.color)
                }
                .width('100%')
                .layoutWeight(1)
                .height(10)
                .borderRadius(5)
                .backgroundColor('#F5EDE7')
                .justifyContent(FlexAlign.Start)
                .clip(true)
                Text(f.pct + '')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.textMain)
                  .width(28)
                  .textAlign(TextAlign.End)
              }
              .width('100%')
              .margin({ top: 6, bottom: 6 })
            }, (f: FlavorItem173) => 'det' + f.name)
          }
          .width('100%')
          .margin({ top: 6 })

          Text('冲煮建议')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
            .width('100%')
            .margin({ top: 12 })
          Text('• 研磨:中细(白砂糖)\n• 水温:92℃ · 粉水比 1:15\n• 两段式注水,总时长 2分30秒')
            .fontSize(12)
            .fontColor(COLORS173.textSub)
            .lineHeight(20)
            .width('100%')
            .margin({ top: 6 })

          Text('豆友评价')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
            .width('100%')
            .margin({ top: 12 })

          Column() {
            ForEach(['手冲党小k', '奶咖星人', '浅烘控阿橙'], (u: string) => {
              Row() {
                Text(u)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.textMain)
                Text('')
                  .layoutWeight(1)
                Text('★★★★★')
                  .fontSize(10)
                  .fontColor(COLORS173.gold)
              }
              .width('100%')
              .margin({ top: 6 })
            }, (u: string) => u)
          }
          .width('100%')
          .margin({ top: 4 })

          Row() {
            Text('收藏')
              .fontSize(13)
              .fontColor(COLORS173.coffeeDeep)
              .padding({ left: 24, right: 24, top: 10, bottom: 10 })
              .borderRadius(20)
              .border({ width: 1, color: COLORS173.coffee })
            Text('')
              .layoutWeight(1)
            Text('立即下单烘焙')
              .fontSize(14)
              .fontColor(COLORS173.white)
              .fontWeight(FontWeight.Bold)
              .padding({ left: 24, right: 24, top: 10, bottom: 10 })
              .borderRadius(20)
              .backgroundColor(COLORS173.caramel)
              .onClick(() => {
                this.onClose();
              })
          }
          .width('100%')
          .margin({ top: 14 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14 })
      }
      .width('92%')
      .constraintSize({ maxHeight: '85%' })
      .backgroundColor(COLORS173.cardBg)
      .borderRadius(20)
      .clip(true)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#00000000')
  }
}

溯源解读

各位访客,这是本次展览中最丰富的展品——咖啡豆详情全屏弹窗。BeanDetailDialog173通过bindContentCover绑定显示,其结构分为三层:最外层Column占满全屏并设置透明背景(#00000000),通过justifyContent(FlexAlign.Center)将内容居中;中间层是卡片容器(宽92%、最大高度85%、圆角20);最内层是具体的展示内容。

弹窗顶部的渐变头图区域使用了从焦糖橙到深咖啡棕的135度对角渐变,内含咖啡豆图标和杯测分数标签。backgroundColor('#33FFFFFF')中的"33"表示约20%透明度的白色,创建了半透明的分数标签背景。在ArkTS中,颜色值可以使用8位十六进制(ARGB)格式,前两位是Alpha通道("00"完全透明,"FF"完全不透明)。

8位十六进制颜色在ArkTS中广泛使用,特别是在渐变背景上叠加半透明元素时。常用的透明度值包括:“33”(约20%)、“80”(约50%)、“B3”(约70%)、“CC”(约80%)、“D9”(约85%)。选择哪种透明度取决于底层背景的复杂度和上层文字的可读性需求。

风味谱是详情弹窗中最具数据可视化价值的部分。ForEach渲染4个风味维度(茉莉花香、柠檬酸质、蜂蜜甜感、红茶尾韵),每个维度显示名称、进度条和百分比数值。进度条的背景色直接来自FlavorItem173接口中的color字段——这种"数据自带颜色"的设计使得可视化组件无需额外的颜色计算函数,直接使用接口数据渲染即可。keyGenerator使用了'det' + f.name前缀,与豆单页面的风味条区分开来,避免键值冲突。

冲煮建议使用了\n换行符在单个Text中实现多行文本,配合lineHeight(20)设置行高。这种"单Text多行"的方式比使用多个Text组件更简洁,适用于固定格式的参数说明文本。文本使用符号作为列表标记,模拟了Markdown的列表效果。

豆友评价列表使用静态字符串数组['手冲党小k', '奶咖星人', '浅烘控阿橙']作为ForEach的数据源,每条评价显示用户名和五星评分。Text('★★★★★')使用Unicode星号字符显示五星评价,颜色为金色(COLORS173.gold)。这种"静态数据+ForEach"的方式在展示少量固定内容时比手动编写多个Row更为简洁。

底部操作区包含"收藏"和"立即下单烘焙"两个按钮。"收藏"使用轮廓样式(咖啡棕边框),"立即下单烘焙"使用焦糖橙实色背景。这种"弱化收藏、强化下单"的视觉设计引导用户进行转化操作。constraintSize({ maxHeight: '85%' })限制卡片最大高度为85%,配合clip(true)裁剪超出部分,确保弹窗在内容过多时不会超出屏幕。

馆藏技术点对比

序号 技术点 类别 馆藏位置 作用说明 策展评价
1 @Entry 装饰器 CoffeeMain173 标记页面入口组件 镇馆之宝级核心展品
2 @Component 装饰器 全部struct 声明自定义组件 共12件馆藏组件
3 @State 装饰器 入口和弹窗组件 响应式状态管理 共10+4+3+3+3+1个状态变量
4 @Builder 装饰器 入口组件内 定义可复用UI构建方法 pageHeader/tabRows/contentArea等
5 interface TypeScript 模块级别 定义数据模型接口 共9个接口定义
6 ForEach ArkUI组件 列表渲染 根据数据列表循环渲染 配合keyGenerator增量更新
7 if/else 条件渲染 contentArea/Tab内 条件分支渲染 页面切换和列表过滤
8 Scroll ArkUI组件 各页面容器 可滚动容器 支持水平和垂直滚动
9 bindSheet 弹窗绑定 入口组件build 半模态底部弹窗 3个sheet用于表单输入
10 bindContentCover 弹窗绑定 入口组件build 全模态覆盖弹窗 2个cover用于确认和详情
11 $$ 双向绑定 bindSheet参数 状态与弹窗显隐同步 $$this.showXxx语法
12 linearGradient 样式属性 头部、卡片 线性渐变背景 135度对角渐变为主
13 layoutWeight 布局属性 全局 按比例分配剩余空间 弹性间隔和等宽分布
14 aboutToAppear 生命周期 RoastEditSheet173 组件创建后初始化 同步外部参数到内部状态
15 TextInput 交互组件 编辑弹窗 文本输入 配合onChange同步状态
16 textAlign 样式属性 全局 文本对齐方式 Start/Center/End三种对齐
17 clip 样式属性 进度条、弹窗 裁剪超出边界的内容 配合borderRadius实现圆角裁剪
18 constraintSize 样式属性 详情弹窗 约束组件最大尺寸 maxHeight限制弹窗高度
19 justifyContent 布局属性 全局 主轴对齐方式 Start/Center/End控制内容位置
20 maxLines 样式属性 文本组件 限制最大行数 配合textOverflow截断显示

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 多多精品咖啡 · 烘焙订阅工坊
// 拼多多风格:咖啡棕 × 奶泡米,双排 Tab(上排豆仓/下排工具)

interface ColorPalette173 {
  coffee: string;
  coffeeDeep: string;
  milk: string;
  milkDeep: string;
  caramel: string;
  bg: string;
  cardBg: string;
  textMain: string;
  textSub: string;
  textHint: string;
  border: string;
  danger: string;
  white: string;
  green: string;
  gold: string;
}

const COLORS173: ColorPalette173 = {
  coffee: '#4E342E',
  coffeeDeep: '#3E2723',
  milk: '#D7CCC8',
  milkDeep: '#BCAAA4',
  caramel: '#E64A19',
  bg: '#FBF7F4',
  cardBg: '#FFFFFF',
  textMain: '#3E2723',
  textSub: '#8D6E63',
  textHint: '#BCAAA4',
  border: '#EFE5E0',
  danger: '#D84315',
  white: '#FFFFFF',
  green: '#2E7D32',
  gold: '#E65100'
};

interface TabItem173 {
  key: string;
  icon: string;
  label: string;
  row: number;
}

const TABS173: TabItem173[] = [
  { key: 'beans', icon: '☕', label: '豆单', row: 1 },
  { key: 'roast', icon: '🔥', label: '烘焙', row: 1 },
  { key: 'brew', icon: '🧋', label: '冲煮', row: 1 },
  { key: 'sub', icon: '📅', label: '订阅', row: 2 },
  { key: 'estate', icon: '🏆', label: '庄园', row: 2 },
  { key: 'mine', icon: '👤', label: '我的', row: 2 }
];

interface BeanItem173 {
  name: string;
  origin: string;
  icon: string;
  process: string;
  price: number;
  notes: string;
  score: number;
  tag: string;
}

const BEANS173: BeanItem173[] = [
  { name: '耶加雪菲·科契尔', origin: '埃塞俄比亚', icon: '🫘', process: '水洗', price: 68, notes: '茉莉·柠檬·蜂蜜', score: 86, tag: '花果炸裂' },
  { name: '西达摩·花魁', origin: '埃塞俄比亚', icon: '🌸', process: '日晒', price: 88, notes: '玫瑰·莓果·可可', score: 88, tag: '限量到货' },
  { name: '瑰夏·翡翠庄园', origin: '巴拿马', icon: '💚', process: '水洗', price: 268, notes: '柑橘·白花·蜜瓜', score: 92, tag: '竞标级' },
  { name: '曼特宁·苏门答腊', origin: '印度尼西亚', icon: '🌿', process: '湿刨', price: 45, notes: '草本·黑巧·香料', score: 82, tag: '醇厚派' },
  { name: '哥伦比亚·蕙兰', origin: '哥伦比亚', icon: '⛰', process: '水洗', price: 52, notes: '焦糖·坚果·苹果', score: 84, tag: '口粮首选' },
  { name: '云南·保山小粒', origin: '中国云南', icon: '🌾', process: '日晒', price: 38, notes: '红糖·梅子·糯香', score: 83, tag: '国产之光' },
  { name: '肯尼亚·AA 涅里', origin: '肯尼亚', icon: '🔴', process: '水洗', price: 72, notes: '黑醋栗·番茄·乌梅', score: 87, tag: '酸质担当' },
  { name: '危地马拉·安提瓜', origin: '危地马拉', icon: '🌋', process: '水洗', price: 58, notes: '烟熏·可可·橙皮', score: 85, tag: '火山土壤' },
  { name: '巴西·喜拉多', origin: '巴西', icon: '🌰', process: '半日晒', price: 42, notes: '花生·牛奶巧克力', score: 80, tag: '奶咖基底' },
  { name: '哥斯达黎加·蜜处理', origin: '哥斯达黎加', icon: '🍯', process: '红蜜', price: 65, notes: '枫糖·红酒·杏仁', score: 86, tag: '甜感突出' },
  { name: '洪都拉斯·雪莉', origin: '洪都拉斯', icon: '🥃', process: '酒桶发酵', price: 78, notes: '威士忌·香草·奶油', score: 87, tag: '微醺风味' },
  { name: '埃塞·74158 微批次', origin: '埃塞俄比亚', icon: '🫐', process: '厌氧日晒', price: 96, notes: '蓝莓·荔枝·发酵香', score: 89, tag: '实验批' }
];

interface OriginStat173 {
  region: string;
  pct: number;
  color: string;
}

const ORIGINS173: OriginStat173[] = [
  { region: '非洲', pct: 38, color: '#E64A19' },
  { region: '中南美洲', pct: 34, color: '#4E342E' },
  { region: '亚洲·海岛', pct: 20, color: '#8D6E63' },
  { region: '中国云南', pct: 8, color: '#2E7D32' }
];

interface RoastBatch173 {
  batch: string;
  bean: string;
  level: string;
  loss: number;
  date: string;
  score: number;
}

const ROASTS173: RoastBatch173[] = [
  { batch: 'R-0825-01', bean: '耶加雪菲', level: '浅烘', loss: 12, date: '08-25', score: 86 },
  { batch: 'R-0824-03', bean: '云南保山', level: '中烘', loss: 15, date: '08-24', score: 84 },
  { batch: 'R-0824-02', bean: '哥伦比亚', level: '中深烘', loss: 17, date: '08-24', score: 83 },
  { batch: 'R-0823-05', bean: '曼特宁', level: '深烘', loss: 19, date: '08-23', score: 82 },
  { batch: 'R-0823-04', bean: '西达摩', level: '浅烘', loss: 11, date: '08-23', score: 88 },
  { batch: 'R-0822-02', bean: '巴西喜拉多', level: '中烘', loss: 14, date: '08-22', score: 80 },
  { batch: 'R-0822-01', bean: '洪都拉斯雪莉', level: '中深烘', loss: 16, date: '08-22', score: 87 },
  { batch: 'R-0821-06', bean: '肯尼亚AA', level: '浅烘', loss: 12, date: '08-21', score: 87 }
];

interface BrewMethod173 {
  name: string;
  icon: string;
  ratio: string;
  mins: number;
  grind: string;
  temp: string;
}

const BREWS173: BrewMethod173[] = [
  { name: '手冲 V60', icon: '🫗', ratio: '1:15', mins: 3, grind: '白砂糖粗', temp: '92℃' },
  { name: '法压壶', icon: '🫖', ratio: '1:12', mins: 4, grind: '海盐粗', temp: '93℃' },
  { name: '冷萃', icon: '🧊', ratio: '1:10', mins: 720, grind: '特粗', temp: '常温' },
  { name: '摩卡壶', icon: '♨️', ratio: '1:8', mins: 5, grind: '细盐', temp: '蒸汽' },
  { name: '爱乐压', icon: '🥤', ratio: '1:14', mins: 2, grind: '细砂糖', temp: '85℃' },
  { name: '虹吸壶', icon: '⚗️', ratio: '1:13', mins: 6, grind: '中细', temp: '90℃' }
];

interface GearItem173 {
  name: string;
  icon: string;
  price: number;
  sold: number;
  cat: string;
}

const GEARS173: GearItem173[] = [
  { name: 'Hario V60 树脂 02', icon: '🫗', price: 68, sold: 5400, cat: '滤杯' },
  { name: 'Kalita 蛋糕杯 155', icon: '🍰', price: 88, sold: 2100, cat: '滤杯' },
  { name: 'Origami 折纸滤杯', icon: '🪷', price: 108, sold: 1600, cat: '滤杯' },
  { name: 'Fellow Stagg 手冲壶', icon: '🫖', price: 899, sold: 860, cat: '温控' },
  { name: '泰摩 鱼Smart壶', icon: '🐟', price: 399, sold: 3200, cat: '温控' },
  { name: '1Zpresso K系列', icon: '⚙️', price: 1280, sold: 1900, cat: '磨豆' },
  { name: '泰摩 栗子C3', icon: '🌰', price: 368, sold: 8900, cat: '磨豆' },
  { name: 'Comandante C40', icon: '🇩🇪', price: 2680, sold: 620, cat: '磨豆' },
  { name: 'Fellow 恒温杯', icon: '🥛', price: 259, sold: 1400, cat: '温控' },
  { name: 'Brewista 智能秤', icon: '⚖️', price: 459, sold: 2600, cat: '称量' },
  { name: 'Hario 云朵分享壶', icon: '☁️', price: 96, sold: 4100, cat: '滤杯' },
  { name: 'Kinto 冷萃瓶', icon: '🧊', price: 179, sold: 2300, cat: '冷萃' }
];

interface EstateItem173 {
  name: string;
  country: string;
  icon: string;
  score: number;
  alt: string;
  var: string;
}

const ESTATES173: EstateItem173[] = [
  { name: '翡翠庄园', country: '巴拿马', icon: '💎', score: 95, alt: '1650m', var: '瑰夏' },
  { name: '九十+ 烛芒', country: '埃塞俄比亚', icon: '🕯', score: 92, alt: '1950m', var: '原生种' },
  { name: '鲁瓦卡庄园', country: '埃塞俄比亚', icon: '🦝', score: 90, alt: '1850m', var: '74110' },
  { name: '蓝山一号园', country: '牙买加', icon: '⛰', score: 91, alt: '1600m', var: '蓝山' },
  { name: '柯契尔合作社', country: '埃塞俄比亚', icon: '🌺', score: 89, alt: '1900m', var: ' heirloom' },
  { name: '慧兰产区联庄', country: '哥伦比亚', icon: '🚜', score: 87, alt: '1750m', var: '卡杜拉' },
  { name: '涅槃庄园', country: '危地马拉', icon: '🌋', score: 88, alt: '1580m', var: '波旁' },
  { name: '铃木处理厂', country: '巴西', icon: '🌰', score: 84, alt: '1150m', var: '黄波旁' },
  { name: '阿萨庄园', country: '肯尼亚', icon: '🦁', score: 89, alt: '1800m', var: 'SL28' },
  { name: '芒廷庄园', country: '巴拿马', icon: '🌄', score: 90, alt: '1550m', var: '卡杜艾' },
  { name: '云雾庄园', country: '中国云南', icon: '🌫', score: 86, alt: '1680m', var: '卡蒂姆' },
  { name: '金鹰庄园', country: '洪都拉斯', icon: '🦅', score: 87, alt: '1450m', var: '帕卡斯' }
];

interface SubHistory173 {
  box: string;
  beans: string;
  date: string;
  status: string;
}

const SUB_HISTORY173: SubHistory173[] = [
  { box: '第 14 期 · 探索盒', beans: '耶加雪菲+云南保山', date: '08-20 送达', status: '已冲煮' },
  { box: '第 13 期 · 探索盒', beans: '肯尼亚AA+巴西', date: '07-20 送达', status: '已冲煮' },
  { box: '第 12 期 · 大师盒', beans: '翡翠瑰夏 50g', date: '06-20 送达', status: '已冲煮' },
  { box: '第 11 期 · 探索盒', beans: '洪都拉斯+危地马拉', date: '05-20 送达', status: '已冲煮' },
  { box: '第 10 期 · 口粮盒', beans: '哥伦比亚 454g', date: '04-20 送达', status: '已冲煮' }
];

interface OrderItem173 {
  name: string;
  icon: string;
  price: number;
  status: string;
  date: string;
}

const ORDERS173: OrderItem173[] = [
  { name: '西达摩花魁 200g', icon: '🌸', price: 88, status: '烘焙中', date: '08-25' },
  { name: '栗子C3 磨豆机', icon: '🌰', price: 368, status: '已发货', date: '08-16' },
  { name: 'Origami 滤杯+滤纸', icon: '🪷', price: 128, status: '已完成', date: '08-02' },
  { name: '冷萃套装', icon: '🧊', price: 179, status: '已完成', date: '07-21' },
  { name: '洪都拉斯雪莉 250g', icon: '🥃', price: 78, status: '待评价', date: '07-08' }
];

function roastColor173(lv: string): string {
  if (lv === '浅烘') {
    return '#C58A5A';
  }
  if (lv === '中烘') {
    return '#8D6E63';
  }
  if (lv === '中深烘') {
    return '#5D4037';
  }
  return '#3E2723';
}

interface FlavorItem173 {
  name: string;
  pct: number;
  color: string;
}

const MY_FLAVORS173: FlavorItem173[] = [
  { name: '花香酸质', pct: 88, color: '#E64A19' },
  { name: '莓果发酵', pct: 76, color: '#8E24AA' },
  { name: '坚果可可', pct: 64, color: '#8D6E63' },
  { name: '奶油酒香', pct: 52, color: '#E65100' }
];

const BEAN_FLAVORS173: FlavorItem173[] = [
  { name: '茉莉花香', pct: 90, color: '#E64A19' },
  { name: '柠檬酸质', pct: 84, color: '#F9A825' },
  { name: '蜂蜜甜感', pct: 78, color: '#E65100' },
  { name: '红茶尾韵', pct: 62, color: '#8D6E63' }
];

function scoreC173(s: number): string {
  if (s >= 90) {
    return COLORS173.gold;
  }
  if (s >= 85) {
    return COLORS173.caramel;
  }
  return COLORS173.textSub;
}

function scoreBar173(s: number): string {
  return (s - 70) + '%';
}

function lossH173(l: number): string {
  return (l * 5) + '%';
}

function processC173(p: string): string {
  if (p === '水洗') {
    return '#0277BD';
  }
  if (p === '日晒') {
    return '#E65100';
  }
  if (p === '厌氧日晒') {
    return '#8E24AA';
  }
  return '#2E7D32';
}

@Entry
@Component
struct CoffeeMain173 {
  @State curTab: string = 'beans';
  @State showBuy: boolean = false;
  @State showSub: boolean = false;
  @State showRoastEdit: boolean = false;
  @State showCancelSub: boolean = false;
  @State showDetail: boolean = false;
  @State buyName: string = '耶加雪菲·科契尔';
  @State roastBatch: string = 'R-0825-01';
  @State detailName: string = '耶加雪菲·科契尔';
  @State detailIcon: string = '🫘';

  @Builder
  pageHeader() {
    Column() {
      Row() {
        Text('☕')
          .fontSize(22)
        Text('多多精品咖啡')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.white)
          .margin({ left: 6 })
        Text('烘焙坊直发')
          .fontSize(10)
          .fontColor(COLORS173.coffeeDeep)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(9)
          .backgroundColor('#EFB76A')
          .margin({ left: 10 })
        Text('')
          .layoutWeight(1)
        Text('🔍')
          .fontSize(17)
          .width(34)
          .height(34)
          .textAlign(TextAlign.Center)
          .borderRadius(17)
          .backgroundColor('#33FFFFFF')
          .margin({ right: 8 })
        Text('🛒')
          .fontSize(17)
          .width(34)
          .height(34)
          .textAlign(TextAlign.Center)
          .borderRadius(17)
          .backgroundColor('#33FFFFFF')
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Text('🔥 今日鲜烘 · 下单后 48h 内出炉')
          .fontSize(12)
          .fontColor('#FFFFFFD9')
        Text('')
          .layoutWeight(1)
        Text('满 199 减 30')
          .fontSize(11)
          .fontColor(COLORS173.caramel)
          .fontWeight(FontWeight.Bold)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .borderRadius(11)
          .backgroundColor('#FFFFFF')
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 10, bottom: 14 })
    .linearGradient({
      angle: 135,
      colors: [[COLORS173.coffee, 0.0], [COLORS173.coffeeDeep, 1.0]]
    })
  }

  @Builder
  tabRows() {
    Column() {
      Row() {
        ForEach(TABS173, (t: TabItem173) => {
          if (t.row === 1) {
            Column() {
              Text(t.icon)
                .fontSize(16)
              Text(t.label)
                .fontSize(12)
                .fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.curTab === t.key ? COLORS173.caramel : COLORS173.textSub)
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 8, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.curTab === t.key ? '#FBE9E7' : '#FFFFFF')
            .onClick(() => {
              this.curTab = t.key;
            })
          }
        }, (t: TabItem173) => t.key)
      }

      Row() {
        ForEach(TABS173, (t: TabItem173) => {
          if (t.row === 2) {
            Column() {
              Text(t.icon)
                .fontSize(16)
              Text(t.label)
                .fontSize(12)
                .fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.curTab === t.key ? COLORS173.caramel : COLORS173.textSub)
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 8, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.curTab === t.key ? '#FBE9E7' : '#FFFFFF')
            .onClick(() => {
              this.curTab = t.key;
            })
          }
        }, (t: TabItem173) => t.key)
      }
    }
    .width('100%')
    .padding({ left: 12, right: 12 })
    .margin({ top: 10 })
  }

  @Builder
  buySheet() {
    BeanBuySheet173({
      beanName: this.buyName,
      onClose: (): void => {
        this.showBuy = false;
      }
    })
  }

  @Builder
  subSheet() {
    SubSheet173({
      onClose: (): void => {
        this.showSub = false;
      }
    })
  }

  @Builder
  roastEditSheet() {
    RoastEditSheet173({
      batchName: this.roastBatch,
      onClose: (): void => {
        this.showRoastEdit = false;
      }
    })
  }

  @Builder
  cancelSubDialog() {
    CancelSubDialog173({
      onCancel: (): void => {
        this.showCancelSub = false;
      },
      onConfirm: (): void => {
        this.showCancelSub = false;
      }
    })
  }

  @Builder
  detailDialog() {
    BeanDetailDialog173({
      beanName: this.detailName,
      beanIcon: this.detailIcon,
      onClose: (): void => {
        this.showDetail = false;
      }
    })
  }

  @Builder
  contentArea() {
    Column() {
      if (this.curTab === 'beans') {
        BeansTab173({
          onBuy: (n: string): void => {
            this.buyName = n;
            this.showBuy = true;
          },
          onDetail: (n: string, ic: string): void => {
            this.detailName = n;
            this.detailIcon = ic;
            this.showDetail = true;
          }
        })
      } else if (this.curTab === 'roast') {
        RoastTab173({
          onEdit: (n: string): void => {
            this.roastBatch = n;
            this.showRoastEdit = true;
          }
        })
      } else if (this.curTab === 'brew') {
        BrewTab173({
          onBuy: (n: string): void => {
            this.buyName = n;
            this.showBuy = true;
          }
        })
      } else if (this.curTab === 'sub') {
        SubTab173({
          onCustom: (): void => {
            this.showSub = true;
          },
          onCancelSub: (): void => {
            this.showCancelSub = true;
          }
        })
      } else if (this.curTab === 'estate') {
        EstateTab173({
          onDetail: (n: string, ic: string): void => {
            this.detailName = n;
            this.detailIcon = ic;
            this.showDetail = true;
          }
        })
      } else {
        MineTab173({
          onDetail: (n: string, ic: string): void => {
            this.detailName = n;
            this.detailIcon = ic;
            this.showDetail = true;
          }
        })
      }
    }
    .width('100%')
    .layoutWeight(1)
  }

  build() {
    Column() {
      this.pageHeader()

      this.tabRows()

      this.contentArea()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS173.bg)
    .bindSheet($$this.showBuy, this.buySheet(), {
      height: 620,
      dragBar: true,
      showClose: true,
      backgroundColor: COLORS173.cardBg
    })
    .bindSheet($$this.showSub, this.subSheet(), {
      height: 560,
      dragBar: true,
      showClose: true,
      backgroundColor: COLORS173.cardBg
    })
    .bindSheet($$this.showRoastEdit, this.roastEditSheet(), {
      height: 500,
      dragBar: true,
      showClose: true,
      backgroundColor: COLORS173.cardBg
    })
    .bindContentCover($$this.showCancelSub, this.cancelSubDialog(), {
    })
    .bindContentCover($$this.showDetail, this.detailDialog(), {
    })
  }
}

@Component
struct BeansTab173 {
  onBuy: (n: string) => void = () => {};
  onDetail: (n: string, ic: string) => void = () => {};

  @Builder
  originCard() {
    Column() {
      Row() {
        Text('🌍 在售豆仓产地分布')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
        Text('')
          .layoutWeight(1)
        Text('共 42 款生豆')
          .fontSize(10)
          .fontColor(COLORS173.textHint)
      }
      .width('100%')

      Row() {
        ForEach(ORIGINS173, (o: OriginStat173) => {
          Text('')
            .width(o.pct + '%')
            .height(16)
            .backgroundColor(o.color)
        }, (o: OriginStat173) => o.region)
      }
      .width('100%')
      .height(16)
      .borderRadius(8)
      .clip(true)
      .margin({ top: 10 })

      Row() {
        ForEach(ORIGINS173, (o: OriginStat173) => {
          Row() {
            Text('')
              .width(8)
              .height(8)
              .borderRadius(4)
              .backgroundColor(o.color)
            Text(o.region + ' ' + o.pct + '%')
              .fontSize(10)
              .fontColor(COLORS173.textSub)
              .margin({ left: 4 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Start)
        }, (o: OriginStat173) => 'lg' + o.region)
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS173.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }

  build() {
    Scroll() {
      Column() {
        this.originCard()

        Row() {
          Text('☕ 生豆直采豆单')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('按杯测分排序')
            .fontSize(11)
            .fontColor(COLORS173.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(BEANS173, (b: BeanItem173) => {
            Row() {
              Text(b.icon)
                .fontSize(26)
                .width(60)
                .height(60)
                .textAlign(TextAlign.Center)
                .borderRadius(14)
                .backgroundColor('#F5EDE7')

              Column() {
                Row() {
                  Text(b.name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS173.textMain)
                  Text(b.process)
                    .fontSize(9)
                    .fontColor(COLORS173.white)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(7)
                    .backgroundColor(processC173(b.process))
                    .margin({ left: 6 })
                }

                Text(b.origin + ' · ' + b.notes)
                  .fontSize(11)
                  .fontColor(COLORS173.textSub)
                  .margin({ top: 4 })

                Row() {
                  Text('杯测 ' + b.score)
                    .fontSize(11)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(scoreC173(b.score))
                  Text(b.tag)
                    .fontSize(9)
                    .fontColor(COLORS173.caramel)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(7)
                    .backgroundColor('#FBE9E7')
                    .margin({ left: 8 })
                  Text('')
                    .layoutWeight(1)
                  Text('¥' + b.price + '/200g')
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS173.caramel)
                  Text('下单烘焙')
                    .fontSize(11)
                    .fontColor(COLORS173.white)
                    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                    .borderRadius(11)
                    .backgroundColor(COLORS173.coffee)
                    .margin({ left: 10 })
                    .onClick(() => {
                      this.onBuy(b.name);
                    })
                }
                .width('100%')
                .margin({ top: 6 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS173.cardBg)
            .borderRadius(14)
            .margin({ top: 10 })
            .onClick(() => {
              this.onDetail(b.name, b.icon);
            })
          }, (b: BeanItem173) => b.name)
        }
        .width('100%')

        Text('')
          .fontSize(1)
          .height(14)
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

@Component
struct RoastTab173 {
  onEdit: (n: string) => void = () => {};

  @Builder
  levelCard() {
    Column() {
      Text('🔥 烘焙度谱系')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')

      Row() {
        ForEach(['浅烘', '中烘', '中深烘', '深烘'], (lv: string) => {
          Column() {
            Text('')
              .width('100%')
              .height(34)
              .borderRadius(10)
              .backgroundColor(roastColor173(lv))
            Text(lv)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS173.textMain)
              .margin({ top: 5 })
            Text('花果酸香')
              .fontSize(9)
              .fontColor(COLORS173.textSub)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .margin({ left: 4, right: 4 })
        }, (lv: string) => lv)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS173.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }

  @Builder
  lossCard() {
    Column() {
      Row() {
        Text('📉 近期批次失重比')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
        Text('')
          .layoutWeight(1)
        Text('失重越高越深烘')
          .fontSize(10)
          .fontColor(COLORS173.textHint)
      }
      .width('100%')

      Row() {
        ForEach(ROASTS173, (r: RoastBatch173, idx: number) => {
          if (idx < 6) {
            Column() {
              Column() {
                Text('')
                  .width('100%')
                  .height(lossH173(r.loss))
                  .borderRadius({ topLeft: 5, topRight: 5 })
                  .backgroundColor(roastColor173(r.level))
              }
              .width('70%')
              .height(100)
              .justifyContent(FlexAlign.End)
              .backgroundColor('#F5EDE7')
              .borderRadius({ topLeft: 5, topRight: 5 })
              .clip(true)

              Text(r.loss + '%')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.textMain)
                .margin({ top: 4 })
              Text(r.batch.slice(3, 8))
                .fontSize(9)
                .fontColor(COLORS173.textSub)
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
        }, (r: RoastBatch173) => 'loss' + r.batch)
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS173.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🔥 我的烘焙坊')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('本月 28 锅')
            .fontSize(11)
            .fontColor(COLORS173.caramel)
        }
        .width('100%')
        .margin({ top: 12 })

        this.levelCard()

        this.lossCard()

        Row() {
          Text('📋 烘焙批次记录')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('共 8 锅')
            .fontSize(11)
            .fontColor(COLORS173.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(ROASTS173, (r: RoastBatch173) => {
            Row() {
              Column() {
                Text(r.level)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.white)
                  .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                  .borderRadius(9)
                  .backgroundColor(roastColor173(r.level))
                Text(r.date)
                  .fontSize(9)
                  .fontColor(COLORS173.textHint)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Center)

              Column() {
                Text(r.batch + ' · ' + r.bean)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.textMain)
                Text('失重 ' + r.loss + '% · 杯测 ' + r.score + ' 分')
                  .fontSize(10)
                  .fontColor(COLORS173.textSub)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })

              Text('编辑')
                .fontSize(11)
                .fontColor(COLORS173.coffee)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(11)
                .border({ width: 1, color: COLORS173.coffee })
                .onClick(() => {
                  this.onEdit(r.batch);
                })
            }
            .width('100%')
            .padding(11)
            .backgroundColor(COLORS173.cardBg)
            .borderRadius(13)
            .margin({ top: 8 })
          }, (r: RoastBatch173) => r.batch)
        }
        .width('100%')
        .margin({ top: 2 })

        Text('')
          .fontSize(1)
          .height(14)
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

@Component
struct BrewTab173 {
  onBuy: (n: string) => void = () => {};

  @Builder
  methodCard() {
    Scroll() {
      Row() {
        ForEach(BREWS173, (m: BrewMethod173) => {
          Column() {
            Text(m.icon)
              .fontSize(32)
            Text(m.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS173.white)
              .margin({ top: 6 })
            Text('粉水 ' + m.ratio + ' · ' + m.grind)
              .fontSize(10)
              .fontColor('#FFFFFFB3')
              .margin({ top: 4 })
            Text(m.temp + ' · ' + (m.mins >= 60 ? (Math.round(m.mins / 60) + '小时') : (m.mins + '分钟')))
              .fontSize(10)
              .fontColor('#EFB76A')
              .margin({ top: 3 })
          }
          .width(170)
          .height(130)
          .padding(12)
          .alignItems(HorizontalAlign.Start)
          .borderRadius(16)
          .linearGradient({
            angle: 135,
            colors: [[COLORS173.caramel, 0.0], [COLORS173.coffeeDeep, 1.0]]
          })
          .margin({ right: 10 })
        }, (m: BrewMethod173) => m.name)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🧋 冲煮方法库')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('6 种主流方案')
            .fontSize(11)
            .fontColor(COLORS173.textHint)
        }
        .width('100%')
        .margin({ top: 12 })

        this.methodCard()

        Row() {
          Text('🛠 器具商城')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('满 2 件 9 折')
            .fontSize(11)
            .fontColor(COLORS173.caramel)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(GEARS173, (g: GearItem173) => {
            Column() {
              Row() {
                Text(g.icon)
                  .fontSize(30)
                Text('')
                  .layoutWeight(1)
                Text(g.cat)
                  .fontSize(9)
                  .fontColor(COLORS173.caramel)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor('#FBE9E7')
              }
              .width('100%')

              Text(g.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.textMain)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')
                .margin({ top: 6 })

              Row() {
                Text('¥' + g.price)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.caramel)
                Text('')
                  .layoutWeight(1)
                Text('已售 ' + g.sold)
                  .fontSize(10)
                  .fontColor(COLORS173.textHint)
              }
              .width('100%')
              .margin({ top: 5 })

              Text('加入清单')
                .fontSize(11)
                .fontColor(COLORS173.white)
                .padding({ left: 14, right: 14, top: 5, bottom: 5 })
                .borderRadius(11)
                .backgroundColor(COLORS173.coffee)
                .margin({ top: 7 })
                .onClick(() => {
                  this.onBuy(g.name);
                })
            }
            .padding(12)
            .backgroundColor(COLORS173.cardBg)
            .borderRadius(14)
            .alignItems(HorizontalAlign.Start)
          }, (g: GearItem173) => g.name)
        }

        Text('')
          .fontSize(1)
          .height(14)
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

@Component
struct SubTab173 {
  onCustom: () => void = () => {};
  onCancelSub: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('☕ 探索盒 · 第 15 期')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.white)
              Text('每月 2 款庄园微批次 · 100g×2')
                .fontSize(11)
                .fontColor('#EFB76A')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('¥89/期')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#EFB76A')
          }
          .width('100%')

          Row() {
            Text('下次发货:09-01 · 顺丰冷链')
              .fontSize(11)
              .fontColor('#FFFFFFB3')
            Text('')
              .layoutWeight(1)
            Text('定制口味')
              .fontSize(12)
              .fontColor(COLORS173.coffeeDeep)
              .fontWeight(FontWeight.Bold)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor('#EFB76A')
              .onClick(() => {
                this.onCustom();
              })
          }
          .width('100%')
          .margin({ top: 12 })
        }
        .width('100%')
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [[COLORS173.coffee, 0.0], [COLORS173.caramel, 1.0]]
        })
        .margin({ top: 12 })

        Row() {
          Text('📦 历史豆单')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('已订阅 14 期')
            .fontSize(11)
            .fontColor(COLORS173.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(SUB_HISTORY173, (h: SubHistory173) => {
            Row() {
              Text('📦')
                .fontSize(20)

              Column() {
                Text(h.box)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.textMain)
                Text(h.beans)
                  .fontSize(11)
                  .fontColor(COLORS173.textSub)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })

              Column() {
                Text(h.status)
                  .fontSize(11)
                  .fontColor(COLORS173.green)
                  .fontWeight(FontWeight.Medium)
                Text(h.date)
                  .fontSize(9)
                  .fontColor(COLORS173.textHint)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS173.cardBg)
            .borderRadius(12)
            .margin({ top: 8 })
          }, (h: SubHistory173) => h.box)
        }
        .width('100%')
        .margin({ top: 2 })

        Row() {
          Column() {
            Text('暂停或终止订阅')
              .fontSize(13)
              .fontColor(COLORS173.textSub)
            Text('已购期次不受影响')
              .fontSize(10)
              .fontColor(COLORS173.textHint)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('退订')
            .fontSize(12)
            .fontColor(COLORS173.danger)
            .padding({ left: 18, right: 18, top: 8, bottom: 8 })
            .borderRadius(14)
            .border({ width: 1, color: COLORS173.danger })
            .onClick(() => {
              this.onCancelSub();
            })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS173.cardBg)
        .borderRadius(14)
        .margin({ top: 14 })

        Text('')
          .fontSize(1)
          .height(14)
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

@Component
struct EstateTab173 {
  onDetail: (n: string, ic: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🏆 庄园口碑榜')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('COE 榜单同步')
            .fontSize(11)
            .fontColor(COLORS173.caramel)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          ForEach(ESTATES173, (e: EstateItem173, idx: number) => {
            if (idx < 5) {
              Row() {
                Text((idx + 1) + '')
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(idx === 0 ? '#EFB76A' : COLORS173.textHint)
                  .width(28)

                Text(e.icon)
                  .fontSize(20)

                Column() {
                  Text(e.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS173.textMain)
                  Text(e.country + ' · ' + e.var)
                    .fontSize(10)
                    .fontColor(COLORS173.textSub)
                    .margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })

                Column() {
                  Text(e.score + '分')
                    .fontSize(12)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(scoreC173(e.score))
                  Row() {
                    Text('')
                      .width(scoreBar173(e.score))
                      .height(5)
                      .borderRadius(3)
                      .backgroundColor(scoreC173(e.score))
                  }
                  .width(52)
                  .height(5)
                  .borderRadius(3)
                  .backgroundColor('#F5EDE7')
                  .justifyContent(FlexAlign.Start)
                  .clip(true)
                  .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%')
              .padding({ top: 11, bottom: 11 })
              .backgroundColor(COLORS173.cardBg)
              .borderRadius(12)
              .margin({ top: 8 })
              .onClick(() => {
                this.onDetail(e.name, e.icon);
              })
            }
          }, (e: EstateItem173) => 'rank' + e.name)
        }
        .width('100%')

        Row() {
          Text('🗺 全部庄园')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('共 12 庄')
            .fontSize(11)
            .fontColor(COLORS173.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(ESTATES173, (e: EstateItem173) => {
            Column() {
              Row() {
                Text(e.icon)
                  .fontSize(26)
                Text('')
                  .layoutWeight(1)
                Text(e.alt)
                  .fontSize(9)
                  .fontColor(COLORS173.white)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor(COLORS173.coffee)
              }
              .width('100%')

              Text(e.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.textMain)
                .width('100%')
                .margin({ top: 6 })

              Text(e.country + ' · ' + e.var)
                .fontSize(10)
                .fontColor(COLORS173.textSub)
                .width('100%')
                .margin({ top: 3 })

              Text('杯测 ' + e.score)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(scoreC173(e.score))
                .width('100%')
                .margin({ top: 5 })
            }
            .padding(12)
            .backgroundColor(COLORS173.cardBg)
            .borderRadius(14)
            .alignItems(HorizontalAlign.Start)
            .onClick(() => {
              this.onDetail(e.name, e.icon);
            })
          }, (e: EstateItem173) => 'grid' + e.name)
        }

        Text('')
          .fontSize(1)
          .height(14)
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

@Component
struct MineTab173 {
  onDetail: (n: string, ic: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('👤 咖啡护照')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('⚙️')
            .fontSize(16)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('🧑‍🍳')
              .fontSize(36)
              .width(58)
              .height(58)
              .textAlign(TextAlign.Center)
              .borderRadius(29)
              .backgroundColor('#33FFFFFF')

            Column() {
              Text('手冲学徒豆仔')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.white)
              Text('打卡 128 杯 · 解锁 22 款豆')
                .fontSize(11)
                .fontColor('#EFB76A')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 12 })

            Text('Lv.12')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS173.coffeeDeep)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .borderRadius(12)
              .backgroundColor('#EFB76A')
          }
          .width('100%')

          Row() {
            ForEach(['喝过豆子', '打卡杯数', '风味标签'], (s: string) => {
              Column() {
                if (s === '喝过豆子') {
                  Text('22 款')
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS173.white)
                } else if (s === '打卡杯数') {
                  Text('128 杯')
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#EFB76A')
                } else {
                  Text('14 个')
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS173.white)
                }
                Text(s)
                  .fontSize(10)
                  .fontColor('#FFFFFFB3')
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (s: string) => s)
          }
          .width('100%')
          .margin({ top: 14 })
        }
        .width('100%')
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [[COLORS173.coffee, 0.0], [COLORS173.coffeeDeep, 1.0]]
        })
        .margin({ top: 12 })

        Row() {
          Text('👅 风味偏好画像')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('根据打卡生成')
            .fontSize(10)
            .fontColor(COLORS173.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(MY_FLAVORS173, (f: FlavorItem173) => {
            Row() {
              Text(f.name)
                .fontSize(12)
                .fontColor(COLORS173.textMain)
                .width(74)
              Row() {
                Text('')
                  .width(f.pct + '%')
                  .height(10)
                  .borderRadius(5)
                  .backgroundColor(f.color)
              }
              .width('100%')
              .layoutWeight(1)
              .height(10)
              .borderRadius(5)
              .backgroundColor('#F5EDE7')
              .justifyContent(FlexAlign.Start)
              .clip(true)
              Text(f.pct + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS173.textMain)
                .width(28)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7, bottom: 7 })
          }, (f: FlavorItem173) => f.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS173.cardBg)
        .borderRadius(16)
        .margin({ top: 10 })

        Row() {
          Text('📦 咖啡订单')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('')
            .layoutWeight(1)
          Text('全部 ›')
            .fontSize(12)
            .fontColor(COLORS173.caramel)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(ORDERS173, (o: OrderItem173) => {
            Row() {
              Text(o.icon)
                .fontSize(21)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .borderRadius(10)
                .backgroundColor('#F5EDE7')

              Column() {
                Text(o.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(COLORS173.textMain)
                Text(o.date + ' · ¥' + o.price)
                  .fontSize(10)
                  .fontColor(COLORS173.textSub)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })

              Text(o.status)
                .fontSize(11)
                .fontColor(o.status === '烘焙中' ? COLORS173.caramel : COLORS173.green)
                .fontWeight(FontWeight.Medium)
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(12)
            .backgroundColor(COLORS173.cardBg)
            .margin({ top: 8 })
            .onClick(() => {
              this.onDetail(o.name, o.icon);
            })
          }, (o: OrderItem173) => o.name)
        }
        .width('100%')
        .margin({ top: 2 })

        Text('')
          .fontSize(1)
          .height(14)
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

@Component
struct BeanBuySheet173 {
  beanName: string = '';
  onClose: () => void = () => {};
  @State selWeight: string = '200g';
  @State selRoast: string = '中烘';
  @State grind: boolean = false;
  @State count: number = 1;

  build() {
    Column() {
      Row() {
        Text('🛒 下单烘焙')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
        Text('')
          .layoutWeight(1)
        Text('出炉即发')
          .fontSize(10)
          .fontColor(COLORS173.white)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(8)
          .backgroundColor(COLORS173.caramel)
      }
      .width('100%')

      Row() {
        Text('☕')
          .fontSize(30)
        Column() {
          Text(this.beanName)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
          Text('下单后 48h 内烘焙 · 顺丰包邮')
            .fontSize(10)
            .fontColor(COLORS173.textSub)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#F5EDE7')
      .borderRadius(14)
      .margin({ top: 12 })

      Text('克重规格')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 14 })
      Row() {
        ForEach(['100g', '200g', '454g', '1kg'], (w: string) => {
          Text(w)
            .fontSize(12)
            .fontColor(this.selWeight === w ? COLORS173.white : COLORS173.textSub)
            .fontWeight(this.selWeight === w ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selWeight === w ? COLORS173.coffee : '#F5EDE7')
            .margin({ right: 8 })
            .onClick(() => {
              this.selWeight = w;
            })
        }, (w: string) => w)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Text('烘焙度')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 14 })
      Row() {
        ForEach(['浅烘', '中烘', '中深烘', '深烘'], (r: string) => {
          Text(r)
            .fontSize(12)
            .fontColor(this.selRoast === r ? COLORS173.white : COLORS173.textMain)
            .fontWeight(this.selRoast === r ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selRoast === r ? roastColor173(r) : '#F5EDE7')
            .margin({ right: 8 })
            .onClick(() => {
              this.selRoast = r;
            })
        }, (r: string) => r)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Row() {
        Column() {
          Text(this.grind ? '磨粉发货' : '整豆发货')
            .fontSize(13)
            .fontWeight(FontWeight.Medium)
            .fontColor(COLORS173.textMain)
          Text(this.grind ? '按所选器具研磨' : '到手自磨风味更佳')
            .fontSize(10)
            .fontColor(COLORS173.textSub)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text(this.grind ? '已开启' : '未开启')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.grind ? COLORS173.caramel : COLORS173.textHint)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .borderRadius(12)
          .backgroundColor(this.grind ? '#FBE9E7' : '#F5EDE7')
          .onClick(() => {
            this.grind = !this.grind;
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#FFFBF7')
      .borderRadius(12)
      .margin({ top: 14 })

      Text('数量')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 14 })
      Row() {
        Text('-')
          .fontSize(18)
          .fontColor(this.count > 1 ? COLORS173.textMain : COLORS173.textHint)
          .width(32)
          .height(32)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#F5EDE7')
          .onClick(() => {
            if (this.count > 1) {
              this.count = this.count - 1;
            }
          })
        Text(this.count + '')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
          .width(48)
          .textAlign(TextAlign.Center)
        Text('+')
          .fontSize(18)
          .fontColor(COLORS173.caramel)
          .width(32)
          .height(32)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#FBE9E7')
          .onClick(() => {
            this.count = this.count + 1;
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 6 })

      Text('')
        .layoutWeight(1)

      Row() {
        Column() {
          Text('合计')
            .fontSize(10)
            .fontColor(COLORS173.textSub)
          Text('¥' + (this.count * 68))
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.caramel)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)

        Text('')
          .layoutWeight(1)

        Text('去结算')
          .fontSize(15)
          .fontColor(COLORS173.white)
          .fontWeight(FontWeight.Bold)
          .padding({ left: 34, right: 34, top: 11, bottom: 11 })
          .borderRadius(22)
          .backgroundColor(COLORS173.caramel)
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')
      .margin({ top: 14 })

      Text('')
        .fontSize(1)
        .height(8)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 18 })
    .backgroundColor(COLORS173.cardBg)
  }
}

@Component
struct SubSheet173 {
  onClose: () => void = () => {};
  @State selFreq: string = '每月一期';
  @State selTaste: string = '花果酸香';
  @State selSize: string = '100g×2';

  build() {
    Column() {
      Row() {
        Text('📅 定制订阅')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
        Text('')
          .layoutWeight(1)
        Text('随时可退')
          .fontSize(10)
          .fontColor(COLORS173.green)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(8)
          .backgroundColor('#E8F5E9')
      }
      .width('100%')

      Text('配送频率')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 16 })
      Row() {
        ForEach(['每月一期', '半月一期', '双月一期'], (f: string) => {
          Text(f)
            .fontSize(12)
            .fontColor(this.selFreq === f ? COLORS173.white : COLORS173.textSub)
            .fontWeight(this.selFreq === f ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selFreq === f ? COLORS173.coffee : '#F5EDE7')
            .margin({ right: 8 })
            .onClick(() => {
              this.selFreq = f;
            })
        }, (f: string) => f)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Text('口味偏好')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 14 })
      Row() {
        ForEach(['花果酸香', '均衡醇厚', '发酵酒香'], (t: string) => {
          Text(t)
            .fontSize(12)
            .fontColor(this.selTaste === t ? COLORS173.white : COLORS173.textSub)
            .fontWeight(this.selTaste === t ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selTaste === t ? COLORS173.caramel : '#FBE9E7')
            .margin({ right: 8 })
            .onClick(() => {
              this.selTaste = t;
            })
        }, (t: string) => t)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Text('每期规格')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 14 })
      Row() {
        ForEach(['100g×2', '200g×1', '454g×1'], (s: string) => {
          Text(s)
            .fontSize(12)
            .fontColor(this.selSize === s ? COLORS173.white : COLORS173.textSub)
            .fontWeight(this.selSize === s ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selSize === s ? COLORS173.coffeeDeep : '#F5EDE7')
            .margin({ right: 8 })
            .onClick(() => {
              this.selSize = s;
            })
        }, (s: string) => s)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Row() {
        Text('订阅价 ¥89/期')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.caramel)
        Text('')
          .layoutWeight(1)
        Text('立省 ¥20/期')
          .fontSize(11)
          .fontColor(COLORS173.green)
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#FFFBF7')
      .borderRadius(12)
      .margin({ top: 14 })

      Text('')
        .layoutWeight(1)

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(COLORS173.textSub)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .borderRadius(20)
          .border({ width: 1, color: COLORS173.border })
          .onClick(() => {
            this.onClose();
          })
        Text('')
          .layoutWeight(1)
        Text('确认定制')
          .fontSize(14)
          .fontColor(COLORS173.white)
          .fontWeight(FontWeight.Bold)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .borderRadius(20)
          .backgroundColor(COLORS173.coffeeDeep)
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')
      .margin({ top: 14 })

      Text('')
        .fontSize(1)
        .height(8)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 18 })
    .backgroundColor(COLORS173.cardBg)
  }
}

@Component
struct RoastEditSheet173 {
  batchName: string = '';
  onClose: () => void = () => {};
  @State batchNo: string = '';
  @State selLevel: string = '浅烘';
  @State lossInput: string = '12';

  aboutToAppear(): void {
    this.batchNo = this.batchName;
  }

  build() {
    Column() {
      Row() {
        Text('🔥 编辑烘焙记录')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
        Text('')
          .layoutWeight(1)
        Text('保存')
          .fontSize(13)
          .fontColor(COLORS173.white)
          .padding({ left: 16, right: 16, top: 7, bottom: 7 })
          .borderRadius(16)
          .backgroundColor(COLORS173.caramel)
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')

      Text('批次号')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 16 })

      TextInput({ placeholder: '如 R-0825-01', text: this.batchNo })
        .fontSize(14)
        .height(44)
        .padding({ left: 12 })
        .borderRadius(10)
        .backgroundColor('#F5EDE7')
        .onChange((v: string) => {
          this.batchNo = v;
        })
        .margin({ top: 8 })

      Text('烘焙度')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 16 })
      Row() {
        ForEach(['浅烘', '中烘', '中深烘', '深烘'], (l: string) => {
          Text(l)
            .fontSize(12)
            .fontColor(this.selLevel === l ? COLORS173.white : COLORS173.textMain)
            .fontWeight(this.selLevel === l ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selLevel === l ? roastColor173(l) : '#F5EDE7')
            .margin({ right: 8 })
            .onClick(() => {
              this.selLevel = l;
            })
        }, (l: string) => l)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Text('失重比(%)')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS173.textMain)
        .width('100%')
        .margin({ top: 16 })

      TextInput({ placeholder: '输入 10-20', text: this.lossInput })
        .fontSize(14)
        .height(44)
        .padding({ left: 12 })
        .borderRadius(10)
        .backgroundColor('#F5EDE7')
        .type(InputType.Number)
        .onChange((v: string) => {
          this.lossInput = v;
        })
        .margin({ top: 8 })

      Text('')
        .layoutWeight(1)

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(COLORS173.textSub)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .borderRadius(20)
          .border({ width: 1, color: COLORS173.border })
          .onClick(() => {
            this.onClose();
          })
        Text('')
          .layoutWeight(1)
        Text('保存记录')
          .fontSize(14)
          .fontColor(COLORS173.white)
          .fontWeight(FontWeight.Bold)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .borderRadius(20)
          .backgroundColor(COLORS173.coffeeDeep)
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')
      .margin({ top: 14 })

      Text('')
        .fontSize(1)
        .height(8)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 18 })
    .backgroundColor(COLORS173.cardBg)
  }
}

@Component
struct CancelSubDialog173 {
  onCancel: () => void = () => {};
  onConfirm: () => void = () => {};
  @State pauseOnly: boolean = true;

  build() {
    Column() {
      Column() {
        Text('☕')
          .fontSize(38)
        Text('退订咖啡订阅')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS173.textMain)
          .margin({ top: 8 })
        Text('第 15 期探索盒将不再发货')
          .fontSize(13)
          .fontColor(COLORS173.textSub)
          .margin({ top: 8 })

        Row() {
          Text(this.pauseOnly ? '☑️' : '⬜')
            .fontSize(15)
          Text('仅暂停一期,下月自动恢复')
            .fontSize(12)
            .fontColor(COLORS173.textMain)
            .margin({ left: 6 })
        }
        .width('100%')
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#F5EDE7')
        .borderRadius(10)
        .justifyContent(FlexAlign.Start)
        .margin({ top: 14 })
        .onClick(() => {
          this.pauseOnly = !this.pauseOnly;
        })

        Text(this.pauseOnly ? '已购期次与积分保留' : '确认后不可恢复,积分清零')
          .fontSize(10)
          .fontColor(this.pauseOnly ? COLORS173.green : COLORS173.danger)
          .margin({ top: 8 })

        Row() {
          Text('再想想')
            .fontSize(14)
            .fontColor(COLORS173.textSub)
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .borderRadius(20)
            .border({ width: 1, color: COLORS173.border })
            .onClick(() => {
              this.onCancel();
            })
          Text('')
            .layoutWeight(1)
          Text('确认退订')
            .fontSize(14)
            .fontColor(COLORS173.white)
            .fontWeight(FontWeight.Bold)
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .borderRadius(20)
            .backgroundColor(COLORS173.danger)
            .onClick(() => {
              this.onConfirm();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('86%')
      .padding({ left: 18, right: 18, top: 22, bottom: 18 })
      .backgroundColor(COLORS173.cardBg)
      .borderRadius(18)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#00000000')
  }
}

@Component
struct BeanDetailDialog173 {
  beanName: string = '';
  beanIcon: string = '';
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text(this.beanIcon)
            .fontSize(44)
          Text('')
            .layoutWeight(1)
          Text('杯测 86 分')
            .fontSize(12)
            .fontColor(COLORS173.white)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .borderRadius(12)
            .backgroundColor('#33FFFFFF')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 20, bottom: 20 })
        .linearGradient({
          angle: 135,
          colors: [[COLORS173.caramel, 0.0], [COLORS173.coffeeDeep, 1.0]]
        })

        Column() {
          Text(this.beanName)
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
            .width('100%')

          Row() {
            Text('水洗处理')
              .fontSize(11)
              .fontColor('#0277BD')
              .padding({ left: 7, right: 7, top: 3, bottom: 3 })
              .borderRadius(8)
              .backgroundColor('#E1F5FE')
            Text('海拔 1900m')
              .fontSize(11)
              .fontColor(COLORS173.textSub)
              .margin({ left: 10 })
            Text('')
              .layoutWeight(1)
            Text('¥68/200g')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS173.caramel)
          }
          .width('100%')
          .margin({ top: 8 })

          Text('风味谱')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
            .width('100%')
            .margin({ top: 12 })

          Column() {
            ForEach(BEAN_FLAVORS173, (f: FlavorItem173) => {
              Row() {
                Text(f.name)
                  .fontSize(12)
                  .fontColor(COLORS173.textMain)
                  .width(74)
                Row() {
                  Text('')
                    .width(f.pct + '%')
                    .height(10)
                    .borderRadius(5)
                    .backgroundColor(f.color)
                }
                .width('100%')
                .layoutWeight(1)
                .height(10)
                .borderRadius(5)
                .backgroundColor('#F5EDE7')
                .justifyContent(FlexAlign.Start)
                .clip(true)
                Text(f.pct + '')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.textMain)
                  .width(28)
                  .textAlign(TextAlign.End)
              }
              .width('100%')
              .margin({ top: 6, bottom: 6 })
            }, (f: FlavorItem173) => 'det' + f.name)
          }
          .width('100%')
          .margin({ top: 6 })

          Text('冲煮建议')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
            .width('100%')
            .margin({ top: 12 })
          Text('• 研磨:中细(白砂糖)\n• 水温:92℃ · 粉水比 1:15\n• 两段式注水,总时长 2分30秒')
            .fontSize(12)
            .fontColor(COLORS173.textSub)
            .lineHeight(20)
            .width('100%')
            .margin({ top: 6 })

          Text('豆友评价')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS173.textMain)
            .width('100%')
            .margin({ top: 12 })

          Column() {
            ForEach(['手冲党小k', '奶咖星人', '浅烘控阿橙'], (u: string) => {
              Row() {
                Text(u)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS173.textMain)
                Text('')
                  .layoutWeight(1)
                Text('★★★★★')
                  .fontSize(10)
                  .fontColor(COLORS173.gold)
              }
              .width('100%')
              .margin({ top: 6 })
            }, (u: string) => u)
          }
          .width('100%')
          .margin({ top: 4 })

          Text('')
            .layoutWeight(1)

          Row() {
            Text('收藏')
              .fontSize(13)
              .fontColor(COLORS173.coffeeDeep)
              .padding({ left: 24, right: 24, top: 10, bottom: 10 })
              .borderRadius(20)
              .border({ width: 1, color: COLORS173.coffee })
            Text('')
              .layoutWeight(1)
            Text('立即下单烘焙')
              .fontSize(14)
              .fontColor(COLORS173.white)
              .fontWeight(FontWeight.Bold)
              .padding({ left: 24, right: 24, top: 10, bottom: 10 })
              .borderRadius(20)
              .backgroundColor(COLORS173.caramel)
              .onClick(() => {
                this.onClose();
              })
          }
          .width('100%')
          .margin({ top: 14 })

          Text('')
            .fontSize(1)
            .height(8)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14 })
      }
      .width('92%')
      .constraintSize({ maxHeight: '85%' })
      .backgroundColor(COLORS173.cardBg)
      .borderRadius(20)
      .clip(true)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#00000000')
  }
}


总结

在这里插入图片描述

本次HarmonyOS ArkTS咖啡烘焙订阅工坊技术策展圆满完成。从色彩馆藏(ColorPalette173)到数据建模(9个interface定义),从辅助函数(5个纯函数)到状态管理(10个@State变量),从双排Tab导航到双模态弹窗系统,每一件馆藏展品都体现了ArkTS声明式UI的核心理念——开发者声明数据与视图的映射关系,框架自动处理渲染、更新、动画等底层逻辑。

在状态管理层面,应用展现了"集中管理与局部隔离"的平衡艺术。入口组件的10个@State变量集中管理了Tab切换和弹窗控制的全局状态,而各弹窗组件内部的@State变量(如selWeightselRoastcount等)则实现了组件内部的局部状态隔离。这种设计既保证了全局状态的统一管理,又避免了局部状态变化对其他组件的不必要影响。回调函数作为子→父通信的唯一通道,确保了数据流的单向性和可追踪性。

在数据可视化层面,应用展示了"纯函数+空Text色块"的轻量级可视化方案。通过roastColor173scoreC173processC173等纯函数将业务数据映射为颜色值,通过lossH173scoreBar173等纯函数将数值映射为百分比,再配合Text('')空文本组件作为色块、Row/Column容器作为轨道,构建了比例条、柱状图、进度条等多种可视化组件。这种方案无需引入第三方图表库,纯ArkTS原生代码即可实现,体现了声明式UI的灵活性。

在交互设计层面,应用的双排Tab导航是最大的结构特色。通过TabItem173接口中的row字段区分上下两排,配合ForEach的条件过滤渲染,实现了6个Tab项的双排布局。这种设计在Tab项较多且可以按功能层级分组时非常实用——上排放核心功能(豆单/烘焙/冲煮),下排放辅助功能(订阅/庄园/我的),信息层级一目了然。bindSheet的3个半模态弹窗用于表单输入场景(购买、订阅、烘焙编辑),bindContentCover的2个全模态弹窗用于详情展示和危险操作确认,两种弹窗的分工明确,交互层级清晰。

在色彩策展层面,整个应用围绕"咖啡棕×奶泡米"的主色调展开,辅以焦糖橙(caramel)作为强调色和金色(gold)作为高光色。roastColor173函数构建了从浅棕到深棕的烘焙度色谱,processC173函数为不同处理法赋予了独特的色彩标识,scoreC173函数通过金色/橙色/灰色的梯度区分评分等级。这些色彩函数不仅服务于视觉美化,更承载了信息传递的功能——用户通过颜色就能快速感知烘焙度、处理法和评分等级。这种"色彩即信息"的设计哲学,是本次策展最核心的馆藏价值所在。

Logo

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

更多推荐