在HarmonyOS ArkTS的开发体系中,声明式UI不仅是一种语法选择,更是一套完整的响应式编程范式。当状态数据发生改变时,框架自动触发UI重新渲染,开发者只需关注数据与视图的映射关系。本实验以一款阳台种菜指南应用为样本,系统性地解构其从色彩定义到组件通信、从数据建模到交互弹窗的完整技术链路。

实验室的核心方法论在于"控制变量、观察现象、记录结果"。将这一方法论引入HarmonyOS应用分析,我们将源码拆解为色彩体系、数据建模、状态管理、UI构建、弹窗交互五大实验模块,逐一观察每个装饰器与组件的行为表现,记录其渲染逻辑与数据流向。

本实验报告采用实验室视角,以"实验目的—实验材料—实验步骤—实验结果"的结构组织全文,力求像在显微镜下观察细胞分裂一样,精确呈现每一行ArkTS代码的技术本质与运行机理。

实验背景与技术架构概述

在这里插入图片描述

在移动端生态日益多元化的今天,HarmonyOS作为华为推出的分布式操作系统,其应用开发框架ArkTS已经演进至API 24版本。ArkTS在TypeScript的基础上进行了深度定制,引入了声明式UI语法和状态管理机制,使得开发者能够以更少的代码实现更复杂的界面交互逻辑。本实验所分析的样本应用,正是一款面向都市阳台种植爱好者的综合服务平台,涵盖当季蔬菜推荐、菜园管理、收获市集、农资补给、农事百科和个人中心六大功能模块。

从技术架构层面来看,该应用采用了典型的"单入口多组件"架构模式。整个应用以一个被@Entry装饰器标记的入口组件为根节点,通过条件渲染机制在六个子页面组件之间进行切换。每个子页面组件都是独立的@Component,它们通过回调函数与父组件进行通信,实现数据的双向流动。这种设计模式在HarmonyOS ArkTS开发中被广泛采用,其优势在于组件职责清晰、状态管理集中、页面切换流畅。

在数据层设计上,应用使用TypeScript的interface定义了多组强类型数据模型,包括蔬菜信息、地块信息、待办事项、农产品、农资商品、农事文章等,每个接口都有明确的字段定义。这些数据模型通过模块级别的常量数组进行初始化,形成了一个完整的静态数据源。在真实应用中,这些数据通常会来自网络请求,但本实验样本采用了硬编码方式,便于我们聚焦于UI渲染逻辑的分析。此外,应用还定义了一组纯函数用于颜色计算、进度条宽度计算等辅助逻辑,这些函数不依赖组件状态,属于无副作用的工具函数,体现了函数式编程的思想。

实验一:色彩体系定义与接口建模

在这里插入图片描述

实验目的

观察ArkTS应用如何通过TypeScript接口建立类型安全的色彩体系与数据模型,验证强类型约束在大型应用中的代码规范作用。

实验材料

interface ColorPalette172 {
  leaf: string;
  leafDeep: string;
  leafLight: string;
  harvest: string;
  harvestDeep: string;
  soil: string;
  bg: string;
  cardBg: string;
  textMain: string;
  textSub: string;
  textHint: string;
  border: string;
  danger: string;
  white: string;
  sky: string;
  yellow: string;
}

const COLORS172: ColorPalette172 = {
  leaf: '#2E7D32',
  leafDeep: '#1B5E20',
  leafLight: '#A5D6A7',
  harvest: '#F57C00',
  harvestDeep: '#E65100',
  soil: '#795548',
  bg: '#F5FAF2',
  cardBg: '#FFFFFF',
  textMain: '#263A28',
  textSub: '#6B8E6E',
  textHint: '#A8C2AA',
  border: '#E3EFDF',
  danger: '#D84315',
  white: '#FFFFFF',
  sky: '#4FC3F7',
  yellow: '#FBC02D'
};

实验步骤与观察

在上述代码中,我们首先观察到interface关键字的使用。interface是TypeScript的核心语法,用于定义对象的类型契约。在HarmonyOS ArkTS中,接口被广泛应用于数据模型的定义,确保所有数据实例都遵循统一的字段规范。ColorPalette172接口定义了17个字符串类型的属性,分别对应应用中使用的所有颜色值,包括主色调(leaf系列)、强调色(harvest系列)、背景色、文本色等。

const COLORS172: ColorPalette172 = {...}这行代码创建了一个实现该接口的常量对象。通过在变量名后添加: ColorPalette172类型注解,TypeScript编译器会在编译阶段检查该对象是否完整实现了接口定义的所有属性。如果遗漏任何一个属性,编译器将报错。这种类型安全机制在大型项目中尤为重要,它可以防止因拼写错误或遗漏字段而导致的运行时异常。

在HarmonyOS ArkTS开发规范中,推荐使用interface定义数据模型而非class,因为ArkTS的UI组件是以声明式方式工作的,数据模型不需要方法行为,只需承载数据字段,interface的轻量级特性更适合这种场景。

继续观察数据模型的定义,可以看到应用定义了多个业务接口:

interface TabItem172 {
  key: string;
  icon: string;
  label: string;
}

interface SeasonVeg172 {
  name: string;
  icon: string;
  score: number;
  days: number;
  level: number;
  price: number;
  tag: string;
}

interface PlotItem172 {
  name: string;
  icon: string;
  progress: number;
  stage: string;
  days: number;
  area: string;
}

interface TodoItem172 {
  icon: string;
  title: string;
  time: string;
  urgent: boolean;
}

interface FarmProduct172 {
  name: string;
  icon: string;
  weight: string;
  price: number;
  sold: number;
  from: string;
  tag: string;
}

interface SupplyItem172 {
  name: string;
  icon: string;
  stock: number;
  price: number;
  unit: string;
  cat: string;
  off: number;
}

实验结果分析

上述六个接口分别定义了Tab导航项、当季蔬菜、地块信息、待办事项、农产品和农资商品的数据结构。每个接口都精确地描述了对应业务实体的字段组成,包括名称、图标、数值、状态标记等。值得注意的是,SeasonVeg172中的score和level字段使用了number类型,这使得后续的评分计算和星级渲染可以直接使用数值进行比较运算。TodoItem172中的urgent字段使用了boolean类型,这种布尔标记在设计紧急任务提醒时非常直观。

这些接口定义完成后,紧接着是常量数组的初始化,例如SEASON_VEGS172、PLOTS172、TODOS172等,每个数组都包含多条预置数据。这种将数据与组件分离的设计模式,使得数据源的替换变得非常容易——只需将硬编码的常量数组替换为网络请求返回的数据,组件代码无需任何修改。

实验二:辅助函数与纯函数体系

在这里插入图片描述

实验目的

验证纯函数在ArkTS应用中的数据处理作用,观察无副作用函数如何为UI渲染提供计算支持。

实验材料

function levelStars172(n: number): string {
  let s = '';
  for (let i = 0; i < 5; i++) {
    s += (i < n) ? '★' : '☆';
  }
  return s;
}

function scoreC172(s: number): string {
  if (s >= 90) {
    return COLORS172.harvestDeep;
  }
  if (s >= 75) {
    return COLORS172.leaf;
  }
  return COLORS172.textSub;
}

function scoreBar172(s: number): string {
  return s + '%';
}

function plotC172(p: number): string {
  if (p >= 100) {
    return COLORS172.harvest;
  }
  if (p >= 60) {
    return COLORS172.leaf;
  }
  return COLORS172.sky;
}

function supplyOffC172(o: number): string {
  if (o >= 20) {
    return COLORS172.danger;
  }
  if (o >= 10) {
    return COLORS172.harvest;
  }
  return COLORS172.textSub;
}

function priceH172(p: number): string {
  let r = Math.round(p / 40 * 100);
  if (r < 12) {
    r = 12;
  }
  return r + '%';
}

实验步骤与观察

在HarmonyOS ArkTS中,函数可以在组件外部定义,也可以在组件内部定义。上述代码展示的是在组件外部定义的模块级函数,它们不属于任何组件,可以被任意组件调用。这种设计保证了函数的复用性——同一个计算逻辑可以被多个组件共享,避免了代码重复。

levelStars172函数接收一个数字参数n,通过for循环拼接星号字符串,返回形如"★★★☆☆"的字符串。这个函数的输出完全由输入决定,不依赖任何外部状态,不修改任何全局变量,是一个典型的纯函数。在HarmonyOS的响应式系统中,纯函数非常适合用于将数据转换为UI需要的格式,因为它们的输出是可预测的、可缓存的。

纯函数的核心特征是:相同的输入永远产生相同的输出,不产生副作用。在ArkTS的声明式UI框架中,纯函数被广泛用于格式化数据、计算样式值、生成UI文本等场景。

scoreC172、plotC172、supplyOffC172这三个函数都遵循相同的模式:接收一个数值参数,通过条件判断返回对应的颜色值。这种"阈值→颜色"的映射模式在数据可视化中非常常见,它将数值差异直观地转化为视觉差异。例如scoreC172将评分90以上映射为丰收深橙色(高亮显示),75以上映射为叶绿色(正常显示),75以下映射为次级文本色(弱化显示)。

priceH172函数展示了一种更复杂的计算逻辑:它将价格转换为柱状图的高度百分比。通过Math.round(p / 40 * 100)将价格除以40并转换为百分比,然后通过if (r < 12) r = 12设置最小高度,避免柱子过矮不可见。这种将业务数据映射为视觉参数的函数,是连接数据层与视觉层的重要桥梁。

实验三:入口组件与状态管理

在这里插入图片描述

实验目的

解构@Entry和@Component装饰器的技术含义,观察@State状态变量如何驱动UI响应式渲染。

实验材料

在这里插入图片描述

@Entry
@Component
struct FarmMain172 {
  @State curTab: string = 'season';
  @State showPlan: boolean = false;
  @State showSell: boolean = false;
  @State showEditPlot: boolean = false;
  @State showDelete: boolean = false;
  @State showDetail: boolean = false;
  @State planName: string = '奶油生菜';
  @State sellName: string = '现摘奶油生菜';
  @State editPlotName: string = '奶油生菜 · 一号盆';
  @State deleteName: string = '小葱 · 窗台槽';
  @State detailName: string = '奶油生菜';
  @State detailIcon: string = '🥬';

  @Builder
  pageHeader() {
    Column() {
      Row() {
        Text('🌾 立秋 · 第2候')
          .fontSize(12)
          .fontColor(COLORS172.white)
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .borderRadius(10)
          .backgroundColor('#33555555')
        Text('')
          .layoutWeight(1)
        Text('多云 26~33℃')
          .fontSize(12)
          .fontColor(COLORS172.white)
        Text('☁️')
          .fontSize(14)
          .margin({ left: 4 })
      }
      .width('100%')

      Row() {
        Column() {
          Text('多多屋顶农场')
            .fontSize(21)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.white)
          Text('阳台一米 · 自给自足')
            .fontSize(11)
            .fontColor('#FFFFFFB3')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Row() {
          Text('🎟')
            .fontSize(17)
          Text('农资券')
            .fontSize(12)
            .fontColor(COLORS172.leafDeep)
            .fontWeight(FontWeight.Medium)
        }
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(COLORS172.white)
        .margin({ right: 8 })

        Text('🔔')
          .fontSize(19)
          .width(36)
          .height(36)
          .textAlign(TextAlign.Center)
          .borderRadius(18)
          .backgroundColor(COLORS172.white)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 10, bottom: 14 })
    .linearGradient({
      angle: 0,
      colors: [[COLORS172.leaf, 0.0], [COLORS172.leafDeep, 1.0]]
    })
  }

实验步骤与观察

在这里插入图片描述

上述代码呈现了HarmonyOS ArkTS应用的核心入口结构。首先看到的是@Entry装饰器。@Entry是ArkTS的入口装饰器,标记该组件为页面入口组件。在一个ArkTS源文件中,只能有一个被@Entry装饰的struct,它代表整个页面的根节点。被@Entry标记的组件会被注册到路由系统中,可以通过页面路由进行跳转。@Entry与@Component通常成对出现,@Component装饰器用于声明一个自定义组件,它告诉编译器这个struct是一个UI组件,可以参与声明式UI的渲染流程。

接下来是@State装饰器。@State是ArkTS的状态管理装饰器,当被@State修饰的变量值发生变化时,框架会自动触发UI重新渲染,将最新的数据反映到界面上。在这个入口组件中,定义了12个@State变量,可以分为三类:Tab切换状态(curTab)、弹窗显隐状态(showPlan、showSell、showEditPlot、showDelete、showDetail)和弹窗上下文数据(planName、sellName、editPlotName、deleteName、detailName、detailIcon)。这种将所有状态集中在根组件管理的设计模式,类似于React中的"状态提升"概念,使得子组件之间的数据传递可以通过父组件作为中介。

@State变量的关键特性在于其响应式机制:当开发者通过赋值操作改变@State变量的值时,ArkTS框架会自动检测变化,并精确地更新依赖该变量的UI部分,而非全量重渲染。这种细粒度的更新机制是ArkTS性能优势的重要来源。

在pageHeader方法上方,我们看到了@Builder装饰器。@Builder用于定义可复用的UI构建函数。被@Builder装饰的方法返回一段声明式UI代码,可以在组件的build方法中通过this.xxx()的方式调用。这种机制类似于其他框架中的"模板函数"或"渲染函数",它将复杂的UI结构封装为可复用的单元,提高代码的可读性和可维护性。在这个例子中,pageHeader方法构建了应用的顶部头部区域,包含节气信息、天气、标题、搜索栏等元素。

在UI组件方面,代码中使用了Column和Row两个基础布局容器。Column是ArkUI提供的纵向线性布局容器,子元素按照垂直方向从上到下排列。Row是横向线性布局容器,子元素按照水平方向从左到右排列。这两个容器是ArkUI布局体系的基础,通过嵌套组合可以实现任意复杂的界面布局。

Text组件用于显示文本内容,支持fontSize(字体大小)、fontColor(字体颜色)、fontWeight(字体粗细)、padding(内边距)、borderRadius(圆角)、backgroundColor(背景色)、margin(外边距)等样式属性。layoutWeight属性用于在父容器中按比例分配剩余空间,类似于CSS的flex-grow。

特别值得关注的是linearGradient属性,它用于设置组件的线性渐变背景。通过angle指定渐变角度,colors数组指定渐变颜色及位置。在这个例子中,头部区域从叶绿色(leaf)渐变到深叶绿色(leafDeep),营造出从上到下的深色渐变效果,增强了视觉层次感。

实验四:Tab导航与条件渲染机制

在这里插入图片描述

实验目的

观察Scroll横向滚动容器与ForEach循环渲染的组合使用,验证if/else条件渲染在页面切换中的表现。

实验材料

@Builder
tabBarRow() {
  Scroll() {
    Row() {
      ForEach(TABS172, (t: TabItem172) => {
        Row() {
          Text(t.icon)
            .fontSize(15)
          Text(t.label)
            .fontSize(13)
            .fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.curTab === t.key ? COLORS172.leafDeep : COLORS172.textSub)
            .margin({ left: 4 })
        }
        .padding({ left: 14, right: 14, top: 8, bottom: 8 })
        .borderRadius(20)
        .backgroundColor(this.curTab === t.key ? '#DCEFD9' : '#FFFFFF')
        .margin({ right: 8 })
        .onClick(() => {
          this.curTab = t.key;
        })
      }, (t: TabItem172) => t.key)
    }
    .width('100%')
    .padding({ left: 12, right: 4 })
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .width('100%')
  .margin({ top: 10 })
}

@Builder
contentArea() {
  Column() {
    if (this.curTab === 'season') {
      SeasonTab172({
        onPlan: (n: string): void => {
          this.planName = n;
          this.showPlan = true;
        },
        onDetail: (n: string, ic: string): void => {
          this.detailName = n;
          this.detailIcon = ic;
          this.showDetail = true;
        }
      })
    } else if (this.curTab === 'garden') {
      GardenTab172({
        onEdit: (n: string): void => {
          this.editPlotName = n;
          this.showEditPlot = true;
        },
        onDelete: (n: string): void => {
          this.deleteName = n;
          this.showDelete = true;
        }
      })
    } else if (this.curTab === 'market') {
      MarketTab172({
        onSell: (n: string): void => {
          this.sellName = n;
          this.showSell = true;
        },
        onDetail: (n: string, ic: string): void => {
          this.detailName = n;
          this.detailIcon = ic;
          this.showDetail = true;
        }
      })
    } else if (this.curTab === 'supply') {
      SupplyTab172({
        onDetail: (n: string, ic: string): void => {
          this.detailName = n;
          this.detailIcon = ic;
          this.showDetail = true;
        }
      })
    } else if (this.curTab === 'farmwork') {
      FarmworkTab172({
        onPlan: (n: string): void => {
          this.planName = n;
          this.showPlan = true;
        }
      })
    } else {
      MineTab172({
        onDetail: (n: string, ic: string): void => {
          this.detailName = n;
          this.detailIcon = ic;
          this.showDetail = true;
        },
        onDelete: (n: string): void => {
          this.deleteName = n;
          this.showDelete = true;
        }
      })
    }
  }
  .width('100%')
  .layoutWeight(1)
}

实验步骤与观察

在tabBarRow构建方法中,首先看到的是Scroll容器。Scroll是ArkUI提供的可滚动容器组件,当内容超出可视区域时,用户可以通过手势滑动查看全部内容。scrollable(ScrollDirection.Horizontal)设置滚动方向为水平滚动,scrollBar(BarState.Off)隐藏滚动条,使界面更加简洁。在Tab数量可能超出屏幕宽度时,横向滚动是一种常见的解决方案。

ForEach是ArkUI提供的循环渲染组件,用于根据数据列表生成UI组件。它接收三个参数:数据源数组、子项生成函数(itemGenerator)和键值生成函数(keyGenerator)。在这个例子中,数据源是TABS172数组,子项生成函数为每个Tab项创建一个包含图标和文字的Row,键值生成函数返回t.key作为唯一标识。ForEach通过键值来识别数据项的唯一性,当数据发生变化时,框架可以精确地进行增量更新,而非全量重建。

ForEach的键值生成函数(keyGenerator)是其性能优化的关键。通过为每个数据项生成唯一键值,框架的虚拟DOM diff算法可以高效地判断哪些项需要新增、删除或更新,从而避免不必要的重渲染。

在Tab项的样式设置中,可以看到三元运算符的使用:this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal。这种条件表达式在声明式UI中非常常见,它根据状态变量的值动态设置组件的样式。当用户点击某个Tab时,onClick回调将curTab设置为该Tab的key值,由于curTab是@State变量,框架自动触发UI重新渲染,所有Tab项的样式都会根据新的curTab值进行更新——被选中的Tab变为粗体深绿色背景,其他Tab变为普通字重的次级文字色。

在contentArea构建方法中,使用了if/else if/else条件渲染来切换不同的页面组件。在ArkTS中,if/else语句可以用于build方法或@Builder方法内部,根据条件渲染不同的UI组件树。当curTab的值发生变化时,框架会销毁旧条件分支的组件树,创建新条件分支的组件树。这种机制与ForEach的增量更新不同,条件渲染是"非此即彼"的切换,适用于页面级别的切换场景。

每个子页面组件都通过参数传递接收回调函数。例如,SeasonTab172接收onPlan和onDetail两个回调函数。当子组件内部触发"做播种计划"按钮点击时,会调用onPlan(v.name),这会执行父组件中定义的箭头函数,将蔬菜名称赋值给planName状态变量,并将showPlan设置为true。这种"子→父"的数据传递模式,是ArkTS组件通信的标准方式之一。

实验五:bindSheet与bindContentCover弹窗机制

实验目的

观察ArkTS两种弹窗容器的技术差异,验证半模态与全模态交互的实现方式。

实验材料

@Builder
planSheet() {
  PlanSheet172({
    vegName: this.planName,
    onClose: (): void => {
      this.showPlan = false;
    }
  })
}

@Builder
sellDialog() {
  SellDialog172({
    productName: this.sellName,
    onCancel: (): void => {
      this.showSell = false;
    },
    onSubmit: (): void => {
      this.showSell = false;
    }
  })
}

@Builder
editPlotSheet() {
  EditPlotSheet172({
    plotName: this.editPlotName,
    onClose: (): void => {
      this.showEditPlot = false;
    }
  })
}

@Builder
deleteDialog() {
  FarmDeleteDialog172({
    targetName: this.deleteName,
    onCancel: (): void => {
      this.showDelete = false;
    },
    onConfirm: (): void => {
      this.showDelete = false;
    }
  })
}

@Builder
detailDialog() {
  VegDetailDialog172({
    vegName: this.detailName,
    vegIcon: this.detailIcon,
    onClose: (): void => {
      this.showDetail = false;
    }
  })
}

build() {
  Column() {
    this.pageHeader()
    this.tabBarRow()
    this.contentArea()
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS172.bg)
  .bindSheet($$this.showPlan, this.planSheet(), {
    height: 580,
    dragBar: true,
    showClose: true,
    backgroundColor: COLORS172.cardBg
  })
  .bindSheet($$this.showEditPlot, this.editPlotSheet(), {
    height: 500,
    dragBar: true,
    showClose: true,
    backgroundColor: COLORS172.cardBg
  })
  .bindContentCover($$this.showSell, this.sellDialog(), {
    backgroundColor: '#00000000'
  })
  .bindContentCover($$this.showDelete, this.deleteDialog(), {
    backgroundColor: '#00000000'
  })
  .bindContentCover($$this.showDetail, this.detailDialog(), {
    backgroundColor: '#00000000'
  })
}

实验步骤与观察

上述代码展示了ArkTS中两种核心弹窗机制。bindSheet是ArkUI提供的方法,用于将一个半模态底部弹窗绑定到组件上。它使用$$双向绑定语法,将一个布尔类型的@State变量与弹窗的显隐状态绑定。当该变量为true时弹窗弹出,为false时弹窗收起。$$语法是ArkTS特有的双向绑定标记,它确保状态变量与UI状态之间保持同步。

bindSheet的配置参数中,height指定弹窗的高度(以像素为单位),dragBar: true显示顶部拖拽条,允许用户通过下拉手势关闭弹窗,showClose: true显示关闭按钮,backgroundColor设置弹窗背景色。这种半模态弹窗适用于表单输入、选择操作等场景,用户可以同时看到部分背景内容。

bindContentCover是另一种弹窗绑定方法,用于全模态覆盖弹窗。与bindSheet不同,bindContentCover会覆盖整个屏幕,通常配合透明背景的遮罩层使用。在这个例子中,backgroundColor: '#00000000'设置为完全透明,而弹窗组件内部会自行绘制半透明遮罩和居中卡片。这种全模态弹窗适用于需要用户明确确认的操作,如删除确认、商品详情等。

bindSheet和bindContentCover的核心差异在于交互层级:前者是"底部抽屉"式的半覆盖交互,后者是"居中对话框"式的全覆盖交互。选择哪种方式取决于业务场景的严重程度和用户注意力的聚焦需求。

每个弹窗构建方法(planSheet、sellDialog等)都返回一个自定义组件实例,并通过参数传递将上下文数据(如蔬菜名称、产品名称)和回调函数传入弹窗组件。这种设计使得弹窗组件可以完全独立于父组件,只需接收数据并通过回调函数通知结果,实现了组件的解耦。

在build方法中,根容器是一个Column,依次排列pageHeader、tabBarRow和contentArea。bindSheet和bindContentCover方法链式调用在Column容器上,这意味着这些弹窗绑定在根容器级别。当任何一个show状态变量变为true时,对应的弹窗就会弹出。由于状态变量都在根组件中管理,弹窗的控制逻辑非常集中,不会出现状态不一致的问题。

实验六:当季蔬菜推荐页面渲染

实验目的

观察ForEach与条件渲染在列表页面中的组合使用,分析评分进度条和星级展示的实现原理。

实验材料

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

  @Builder
  scoreCard() {
    Column() {
      Row() {
        Text('📈 立秋适种指数')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
        Text('')
          .layoutWeight(1)
        Text('按本地气候排序')
          .fontSize(10)
          .fontColor(COLORS172.textHint)
      }
      .width('100%')

      Column() {
        ForEach(SEASON_VEGS172, (v: SeasonVeg172, idx: number) => {
          if (idx < 6) {
            Row() {
              Text(v.icon)
                .fontSize(20)
              Text(v.name)
                .fontSize(12)
                .fontColor(COLORS172.textMain)
                .width(86)

              Row() {
                Text('')
                  .width(scoreBar172(v.score))
                  .height(10)
                  .borderRadius(5)
                  .backgroundColor(scoreC172(v.score))
              }
              .width('100%')
              .layoutWeight(1)
              .height(10)
              .borderRadius(5)
              .backgroundColor('#EDF5EA')
              .justifyContent(FlexAlign.Start)
              .clip(true)

              Text(v.score + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(scoreC172(v.score))
                .width(30)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7, bottom: 7 })
          }
        }, (v: SeasonVeg172) => v.name)
      }
      .width('100%')
      .margin({ top: 4 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS172.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('🥬 秋播黄金期')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS172.white)
            Text('距最佳播期结束还有 18 天')
              .fontSize(11)
              .fontColor('#FFFFFFCC')
              .margin({ top: 4 })
            Row() {
              Text('查看播期表')
                .fontSize(12)
                .fontColor(COLORS172.leafDeep)
                .fontWeight(FontWeight.Bold)
                .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                .borderRadius(14)
                .backgroundColor('#FFFFFF')
                .margin({ top: 10 })
            }
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('🍂')
            .fontSize(52)
            .margin({ right: 8 })
        }
        .width('100%')
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [[COLORS172.harvest, 0.0], [COLORS172.harvestDeep, 1.0]]
        })
        .margin({ top: 12 })

        this.scoreCard()

        Column() {
          ForEach(SEASON_VEGS172, (v: SeasonVeg172) => {
            Column() {
              Row() {
                Text(v.icon)
                  .fontSize(32)
                Text('')
                  .layoutWeight(1)
                Text(v.tag)
                  .fontSize(9)
                  .fontColor(COLORS172.white)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor(COLORS172.harvest)
              }
              .width('100%')

              Text(v.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS172.textMain)
                .width('100%')
                .margin({ top: 8 })

              Text(levelStars172(v.level))
                .fontSize(11)
                .fontColor(COLORS172.leaf)
                .width('100%')
                .margin({ top: 3 })

              Row() {
                Text(v.days + '天收')
                  .fontSize(10)
                  .fontColor(COLORS172.textSub)
                Text('')
                  .layoutWeight(1)
                Text('¥' + v.price)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS172.harvestDeep)
              }
              .width('100%')
              .margin({ top: 6 })

              Text('做播种计划')
                .fontSize(12)
                .fontColor(COLORS172.white)
                .fontWeight(FontWeight.Bold)
                .padding({ left: 16, right: 16, top: 6, bottom: 6 })
                .borderRadius(13)
                .backgroundColor(COLORS172.leaf)
                .margin({ top: 8 })
                .onClick(() => {
                  this.onPlan(v.name);
                })
            }
            .padding(12)
            .backgroundColor(COLORS172.cardBg)
            .borderRadius(14)
            .alignItems(HorizontalAlign.Start)
            .onClick(() => {
              this.onDetail(v.name, v.icon);
            })
          }, (v: SeasonVeg172) => v.name)
        }
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

实验步骤与观察

SeasonTab172是一个被@Component装饰的自定义组件。在HarmonyOS ArkTS中,@Component装饰器用于声明一个自定义组件,被装饰的struct可以拥有自己的@State状态变量、@Builder构建方法和build入口方法。与@Entry组件不同,@Component组件不能被路由直接访问,但可以被其他组件引用和嵌套。

该组件定义了两个成员变量:onPlan和onDetail,它们都是函数类型的变量,默认值为空箭头函数。这种模式是ArkTS中父向子传递回调函数的标准方式。父组件在引用子组件时,通过参数传递将具体的回调实现注入子组件,子组件在特定时机(如按钮点击)调用这些回调,实现数据向父组件的回传。

在scoreCard构建方法中,我们看到了ForEach与if条件渲染的组合使用。ForEach遍历SEASON_VEGS172数组,但在内部使用了if (idx < 6)进行条件过滤,只渲染前6项。这种方式在实现"Top N"列表时非常方便。值得注意的是,ForEach的回调函数可以接收第二个参数idx(索引值),通过索引可以进行分页、过滤等操作。

评分进度条的实现是一个技术亮点。它通过一个外层Row作为背景轨道(背景色为浅绿色#EDF5EA),内部嵌套一个Text('')空文本组件作为进度填充。空文本组件的宽度通过scoreBar172(v.score)计算(返回如"96%"的字符串),背景色通过scoreC172(v.score)计算(高分返回丰收深橙色)。justifyContent(FlexAlign.Start)确保进度条从左侧开始填充,clip(true)裁剪超出部分。这种通过空Text元素作为色块的方式,是ArkTS中一种轻量级的进度条实现方案。

在ArkTS中,任何组件都可以通过设置背景色和尺寸来充当色块、进度条等视觉元素。Text('')空文本是最轻量的选择,因为它不渲染任何文本内容,只保留尺寸和背景属性。

在蔬菜卡片的渲染中,levelStars172(v.level)函数被用于生成星级文本。这个函数返回形如"★★★☆☆"的字符串,通过Text组件以叶绿色显示。这种使用Unicode星号字符替代图标组件的方式,在简单场景下更为高效,避免了引入额外的图标资源。

在卡片结构的底部,"做播种计划"按钮使用了Text组件而非Button组件。在ArkTS中,Text组件可以通过设置backgroundColor、borderRadius、padding等属性实现按钮的视觉效果,再通过onClick添加点击事件。这种"伪按钮"模式在需要完全自定义样式的场景中非常灵活。Button是ArkUI提供的基础交互按钮组件,支持点击事件和预置样式,但自定义自由度不如Text。

实验七:菜园管理页面与进度条系统

实验目的

观察生长进度条的动态渲染逻辑,分析TODO待办事项的紧急程度标记机制。

实验材料

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

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🪴 我的菜园')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('8 盆在种 · 2 盆可收')
            .fontSize(11)
            .fontColor(COLORS172.harvestDeep)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          ForEach(PLOTS172, (p: PlotItem172) => {
            Column() {
              Row() {
                Text(p.icon)
                  .fontSize(24)
                  .width(46)
                  .height(46)
                  .textAlign(TextAlign.Center)
                  .borderRadius(12)
                  .backgroundColor('#EDF5EA')

                Column() {
                  Text(p.name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS172.textMain)
                  Text(p.area + ' · 已种 ' + p.days + ' 天')
                    .fontSize(10)
                    .fontColor(COLORS172.textSub)
                    .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })

                Text(p.stage)
                  .fontSize(11)
                  .fontColor(COLORS172.white)
                  .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                  .borderRadius(10)
                  .backgroundColor(plotC172(p.progress))
              }
              .width('100%')

              Row() {
                Text('')
                  .width(p.progress + '%')
                  .height(8)
                  .borderRadius(4)
                  .backgroundColor(plotC172(p.progress))
              }
              .width('100%')
              .height(8)
              .borderRadius(4)
              .backgroundColor('#EDF5EA')
              .justifyContent(FlexAlign.Start)
              .clip(true)
              .margin({ top: 10 })

              Row() {
                Text('生长进度 ' + p.progress + '%')
                  .fontSize(10)
                  .fontColor(COLORS172.textHint)
                Text('')
                  .layoutWeight(1)
                Text(stageT172(p.progress))
                  .fontSize(10)
                  .fontColor(plotC172(p.progress))
                Text('编辑')
                  .fontSize(11)
                  .fontColor(COLORS172.leaf)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .borderRadius(10)
                  .border({ width: 1, color: COLORS172.leaf })
                  .margin({ left: 10 })
                  .onClick(() => {
                    this.onEdit(p.name);
                  })
                Text('除盆')
                  .fontSize(11)
                  .fontColor(COLORS172.danger)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .borderRadius(10)
                  .border({ width: 1, color: COLORS172.danger })
                  .margin({ left: 6 })
                  .onClick(() => {
                    this.onDelete(p.name);
                  })
              }
              .width('100%')
              .margin({ top: 8 })
            }
            .width('100%')
            .padding(13)
            .backgroundColor(COLORS172.cardBg)
            .borderRadius(14)
            .margin({ top: 10 })
          }, (p: PlotItem172) => p.name)
        }
        .width('100%')

        Column() {
          ForEach(TODOS172, (td: TodoItem172) => {
            Row() {
              Text(td.icon)
                .fontSize(17)
              Column() {
                Text(td.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(COLORS172.textMain)
                Text(td.time)
                  .fontSize(10)
                  .fontColor(td.urgent ? COLORS172.danger : COLORS172.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })

              Text(td.urgent ? '马上' : '稍后')
                .fontSize(10)
                .fontColor(COLORS172.white)
                .padding({ left: 9, right: 9, top: 4, bottom: 4 })
                .borderRadius(9)
                .backgroundColor(td.urgent ? COLORS172.danger : COLORS172.textHint)
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(12)
            .backgroundColor('#FFFBF3')
            .margin({ top: 7 })
          }, (td: TodoItem172) => td.title)
        }
        .width('100%')
        .margin({ top: 8 })
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

实验步骤与观察

GardenTab172组件展示了菜园管理的核心界面。每个地块卡片包含了图标、名称、面积、种植天数、生长阶段标签、进度条和操作按钮。生长进度条的宽度直接使用p.progress + '%'设置,将数值后拼接百分号字符串。进度条颜色通过plotC172(p.progress)函数计算:进度100%返回丰收橙色(表示可采收),60%以上返回叶绿色(表示生长中),60%以下返回天蓝色(表示育苗期)。

在ArkTS的布局系统中,百分比字符串(如"82%")可以被用于width属性,表示占父容器宽度的百分比。这种灵活的尺寸指定方式使得进度条等动态宽度元素可以轻松实现。

在TODO待办事项列表中,td.urgent ? COLORS172.danger : COLORS172.textHint这种三元运算符实现了紧急程度的视觉区分。紧急任务(urgent: true)的时间文字使用危险红色,按钮文字为"马上",背景色为红色;非紧急任务的时间文字使用提示灰色,按钮文字为"稍后",背景色为灰色。这种通过单一布尔值控制多处样式的设计,体现了响应式UI的简洁性。

操作按钮"编辑"和"除盆"使用了border属性而非backgroundColor。border是ArkUI组件的边框属性,通过{ width: 1, color: COLORS172.leaf }设置1像素的叶绿色边框,配合透明背景,形成"轮廓按钮"效果。这种按钮样式与实色按钮形成对比,在操作优先级上自然弱于实色按钮。

实验八:播种计划底部弹窗与表单交互

实验目的

观察bindSheet弹窗内部的表单交互设计,分析选项切换、数量调节等交互组件的实现原理。

实验材料

@Component
struct PlanSheet172 {
  vegName: string = '';
  onClose: () => void = () => {};
  @State selPot: string = '长条盆';
  @State selSeason: string = '秋播';
  @State rows: number = 2;

  build() {
    Column() {
      Row() {
        Text('🌱 播种计划')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
        Text('')
          .layoutWeight(1)
        Text('保存')
          .fontSize(13)
          .fontColor(COLORS172.white)
          .padding({ left: 16, right: 16, top: 7, bottom: 7 })
          .borderRadius(16)
          .backgroundColor(COLORS172.leaf)
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')

      Row() {
        Text('🥬')
          .fontSize(30)
        Column() {
          Text(this.vegName)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('推荐秋播 · 35 天可收 · 发芽率 92%')
            .fontSize(10)
            .fontColor(COLORS172.textSub)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#F3F9F0')
      .borderRadius(14)
      .margin({ top: 12 })

      Text('容器选择')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS172.textMain)
        .width('100%')
        .margin({ top: 14 })

      Row() {
        ForEach(['长条盆', '深盆', '泡沫箱', '营养钵'], (pt: string) => {
          Text(pt)
            .fontSize(12)
            .fontColor(this.selPot === pt ? COLORS172.white : COLORS172.textSub)
            .fontWeight(this.selPot === pt ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 13, right: 13, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.selPot === pt ? COLORS172.leaf : '#F3F9F0')
            .margin({ right: 8 })
            .onClick(() => {
              this.selPot = pt;
            })
        }, (pt: string) => pt)
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      .margin({ top: 8 })

      Row() {
        Column() {
          Text('播种行数')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('每行约 12 穴 · 每穴 2 粒')
            .fontSize(10)
            .fontColor(COLORS172.textHint)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text('-')
          .fontSize(18)
          .color(this.rows > 1 ? COLORS172.textMain : COLORS172.textHint)
          .width(32)
          .height(32)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#F3F9F0')
          .onClick(() => {
            if (this.rows > 1) {
              this.rows = this.rows - 1;
            }
          })
        Text(this.rows + ' 行')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
          .width(56)
          .textAlign(TextAlign.Center)
        Text('+')
          .fontSize(18)
          .fontColor(COLORS172.leaf)
          .width(32)
          .height(32)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#E8F5E9')
          .onClick(() => {
            this.rows = this.rows + 1;
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#FFFBF3')
      .borderRadius(12)
      .margin({ top: 14 })

      Text('')
        .layoutWeight(1)

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(COLORS172.textSub)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .borderRadius(20)
          .border({ width: 1, color: COLORS172.border })
          .onClick(() => {
            this.onClose();
          })
        Text('')
          .layoutWeight(1)
        Text('生成计划')
          .fontSize(14)
          .fontColor(COLORS172.white)
          .fontWeight(FontWeight.Bold)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .borderRadius(20)
          .backgroundColor(COLORS172.leafDeep)
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')
      .margin({ top: 14 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 18 })
    .backgroundColor(COLORS172.cardBg)
  }
}

实验步骤与观察

PlanSheet172是一个被bindSheet调用的底部弹窗组件。它接收vegName参数(蔬菜名称)和onClose回调函数,内部维护三个@State状态变量:selPot(选中的容器类型)、selSeason(选中的播期)和rows(播种行数)。这些状态变量的变化会触发弹窗内部UI的局部更新,而不会影响外部页面的渲染。

选项切换的实现方式非常简洁:通过ForEach渲染所有选项,每个选项的样式通过this.selPot === pt ? ... : ...三元运算符动态设置。被选中的选项显示为白字绿底粗体,未选中的选项显示为灰字浅底普通字重。点击时通过onClick回调将selPot设置为对应选项的值,框架自动触发UI更新。

这种"选中态切换"模式在ArkTS中极为常见,其核心是一个@State变量存储当前选中值,通过比较运算符在每个选项上动态应用样式。当@State变量改变时,所有选项的样式会自动重新计算,无需手动管理选中态的切换逻辑。

数量调节器(stepper)的实现使用了"-"、"+"两个Text组件配合中间的数值显示。减号按钮的onClick回调中包含边界检查if (this.rows > 1),防止行数减到0以下。加号按钮无上限检查。减号按钮在rows等于1时文字颜色变为提示灰色(不可用态),这是通过this.rows > 1 ? COLORS172.textMain : COLORS172.textHint实现的视觉反馈。

弹窗底部使用了Text('').layoutWeight(1)作为弹性间隔,将底部按钮推到弹窗底部。这种"弹性空隙"技巧在ArkTS布局中非常实用,layoutWeight(1)让空Text占据所有剩余空间,从而实现"顶部内容区+底部操作区"的经典弹窗布局。

实验九:商品详情全屏弹窗与信息展示

实验目的

观察bindContentCover全屏弹窗的内部结构设计,分析生长阶段时间轴和用户评价的渲染方式。

实验材料

@Component
struct VegDetailDialog172 {
  vegName: string = '';
  vegIcon: string = '';
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text(this.vegIcon)
            .fontSize(44)
          Text('')
            .layoutWeight(1)
          Text('适种指数 96')
            .fontSize(12)
            .fontColor(COLORS172.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: [[COLORS172.leafLight, 0.0], [COLORS172.leafDeep, 1.0]]
        })

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

          Row() {
            Text('难度 ' + levelStars172(1))
              .fontSize(11)
              .fontColor(COLORS172.leaf)
            Text('35 天收获')
              .fontSize(12)
              .fontColor(COLORS172.textSub)
              .margin({ left: 12 })
            Text('')
              .layoutWeight(1)
            Text('¥6.9/包种')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS172.harvestDeep)
          }
          .width('100%')
          .margin({ top: 8 })

          Text('种植要点')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
            .width('100%')
            .margin({ top: 12 })
          Text('• 催芽:温水浸种 4 小时\n• 间距:株距 10cm 行距 15cm\n• 水肥:见干见湿,采收前停肥')
            .fontSize(12)
            .fontColor(COLORS172.textSub)
            .lineHeight(20)
            .width('100%')
            .margin({ top: 6 })

          Text('生长阶段')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
            .width('100%')
            .margin({ top: 12 })

          Row() {
            ForEach(['播种', '发芽', '间苗', '旺长', '采收'], (st: string, idx: number) => {
              Column() {
                Text((idx + 1) + '')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS172.white)
                  .width(24)
                  .height(24)
                  .textAlign(TextAlign.Center)
                  .borderRadius(12)
                  .backgroundColor(idx < 3 ? COLORS172.leaf : COLORS172.border)
                Text(st)
                  .fontSize(10)
                  .fontColor(COLORS172.textSub)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (st: string) => st)
          }
          .width('100%')
          .margin({ top: 8 })

          Text('农户评价')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
            .width('100%')
            .margin({ top: 12 })

          Column() {
            ForEach(['阳台老李', '种菜小王', '屋顶张姨'], (u: string) => {
              Row() {
                Text(u)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS172.textMain)
                Text('')
                  .layoutWeight(1)
                Text(levelStars172(5))
                  .fontSize(10)
                  .fontColor(COLORS172.harvestDeep)
              }
              .width('100%')
              .margin({ top: 6 })
            }, (u: string) => u)
          }
          .width('100%')
          .margin({ top: 4 })

          Text('')
            .layoutWeight(1)

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

实验步骤与观察

VegDetailDialog172是一个全屏弹窗组件,通过bindContentCover绑定显示。其结构分为两层:外层Column占满全屏并设置透明背景,通过justifyContent(FlexAlign.Center)将内容居中;内层Column是实际的卡片容器,通过width('92%')设置宽度为屏幕的92%,constraintSize({ maxHeight: '85%' })限制最大高度为85%,borderRadius(20)设置圆角,clip(true)裁剪超出圆角的内容。

弹窗顶部是一个渐变头图区域,使用linearGradient从浅叶绿渐变到深叶绿,内含蔬菜图标和适种指数标签。backgroundColor('#33FFFFFF')使用8位十六进制颜色,前两位"33"表示透明度(约20%),后六位"FFFFFF"表示白色。这种带透明度的颜色在创建浮层标签时非常实用。

在ArkTS中,颜色值可以使用6位十六进制(RGB)或8位十六进制(ARGB)格式。8位格式的前两位是Alpha通道,"00"表示完全透明,“FF"表示完全不透明,中间值如"33”(约20%)、“80”(约50%)、“B3”(约70%)是常用的半透明值。

种植要点使用了\n换行符在单个Text中实现多行文本显示,配合lineHeight(20)设置行高。这种"单Text多行"的方式比使用多个Text组件更简洁,适用于固定格式的文本内容。

生长阶段时间轴通过ForEach渲染5个阶段节点,每个节点是一个Column包含数字圆点和阶段名称。已完成阶段(idx < 3)的圆点背景色为叶绿色,未完成阶段为边框色。layoutWeight(1)使每个节点等宽分布,形成横向时间轴效果。

用户评价列表中,每个评价项使用levelStars172(5)生成五星评价文本。Text('').layoutWeight(1)在用户名和星级之间创建弹性间隔,实现"左对齐用户名、右对齐星级"的经典布局。

实验流程图

season

garden

market

supply

farmwork

mine

应用启动

加载入口组件 FarmMain172

初始化 12 个 @State 状态变量

渲染 pageHeader 头部区域

渲染 tabBarRow Tab导航栏

curTab 当前值

SeasonTab172 当季蔬菜页

GardenTab172 菜园管理页

MarketTab172 收获市集页

SupplyTab172 农资补给页

FarmworkTab172 农事百科页

MineTab172 个人中心页

用户点击做播种计划

用户点击编辑菜地

用户点击上架收成

设置 planName + showPlan=true

设置 editPlotName + showEditPlot=true

设置 sellName + showSell=true

bindSheet 弹出 PlanSheet172

bindSheet 弹出 EditPlotSheet172

bindContentCover 弹出 SellDialog172

用户填写表单并保存

用户修改菜地信息并保存

用户设置价格并上架

调用 onClose showPlan=false

调用 onClose showEditPlot=false

调用 onSubmit showSell=false

实验十:农资补给页面与分类筛选

实验目的

观察分类筛选Tab的交互逻辑,分析柱状图数据可视化的实现方式。

实验材料

@Component
struct SupplyTab172 {
  @State selCat: string = '全部';
  onDetail: (n: string, ic: string) => void = () => {};

  @Builder
  catRow() {
    Scroll() {
      Row() {
        ForEach(['全部', '基质', '肥料', '盆器', '工具', '植保'], (c: string) => {
          Text(c)
            .fontSize(12)
            .fontColor(this.selCat === c ? COLORS172.white : COLORS172.textSub)
            .fontWeight(this.selCat === c ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(15)
            .backgroundColor(this.selCat === c ? COLORS172.leaf : '#FFFFFF')
            .margin({ right: 8 })
            .onClick(() => {
              this.selCat = c;
            })
        }, (c: string) => c)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ top: 12 })
  }

  @Builder
  priceCard() {
    Column() {
      Row() {
        Text('📊 热销农资价格')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
        Text('')
          .layoutWeight(1)
        Text('单位 ¥')
          .fontSize(10)
          .fontColor(COLORS172.textHint)
      }
      .width('100%')

      Row() {
        ForEach(SUPPLIES172, (s: SupplyItem172, idx: number) => {
          if (idx < 6) {
            Column() {
              Column() {
                Text('')
                  .width('100%')
                  .height(priceH172(s.price))
                  .borderRadius({ topLeft: 5, topRight: 5 })
                  .backgroundColor(s.price >= 20 ? COLORS172.harvest : COLORS172.leaf)
              }
              .width('72%')
              .height(110)
              .justifyContent(FlexAlign.End)
              .backgroundColor('#EDF5EA')
              .borderRadius({ topLeft: 5, topRight: 5 })
              .clip(true)

              Text('¥' + s.price)
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS172.textMain)
                .margin({ top: 4 })
              Text(s.name.slice(0, 3))
                .fontSize(9)
                .fontColor(COLORS172.textSub)
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
        }, (s: SupplyItem172) => 'price' + s.name)
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS172.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }
}

实验步骤与观察

SupplyTab172组件内部维护了一个@State selCat变量用于分类筛选。分类行通过ForEach渲染6个分类标签,点击时更新selCat的值,框架自动重新渲染所有标签的样式。这种子组件级别的@State管理,使得分类筛选的状态变化不会影响父组件或其他子组件,实现了状态的隔离管理。

子组件的@State变量是组件私有的,不与父组件共享。当子组件被销毁(如Tab切换导致组件被卸载)时,其@State变量的值会丢失。如果需要保持状态,可以在父组件中定义状态并通过参数传递给子组件。

柱状图卡片priceCard是本实验中最具技术亮点的部分之一。每个柱子由一个外层Column(固定高度110,作为坐标系)和一个内层Column(通过height(priceH172(s.price))动态设置高度)组成。justifyContent(FlexAlign.End)使内层柱子在外层容器底部对齐,模拟柱状图从底部生长的效果。柱子颜色根据价格是否超过20元分别使用丰收橙色和叶绿色。borderRadius({ topLeft: 5, topRight: 5 })只设置顶部圆角,使柱子顶部呈圆角状。clip(true)裁剪超出外层容器圆角的部分。

s.name.slice(0, 3)使用JavaScript的slice方法截取商品名前3个字符,作为柱状图下方的标签。在ArkTS中,可以使用标准JavaScript的字符串方法进行文本处理,这体现了ArkTS与TypeScript/JavaScript的兼容性。

实验十一:编辑菜地弹窗与开关组件

实验目的

观察aboutToAppear生命周期回调的作用,分析自定义开关(Toggle)组件的实现方式。

实验材料

@Component
struct EditPlotSheet172 {
  plotName: string = '';
  onClose: () => void = () => {};
  @State plotTitle: string = '';
  @State selLight: string = '全日照';
  @State autoWater: boolean = true;

  aboutToAppear(): void {
    this.plotTitle = this.plotName;
  }

  build() {
    Column() {
      Row() {
        Text('🪴 编辑菜地')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
        Text('')
          .layoutWeight(1)
        Text('保存')
          .fontSize(13)
          .fontColor(COLORS172.white)
          .padding({ left: 16, right: 16, top: 7, bottom: 7 })
          .borderRadius(16)
          .backgroundColor(COLORS172.leaf)
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')

      Text('地块备注名')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS172.textMain)
        .width('100%')
        .margin({ top: 16 })

      TextInput({ placeholder: '给这块地起个名字', text: this.plotTitle })
        .fontSize(14)
        .height(44)
        .padding({ left: 12 })
        .borderRadius(10)
        .backgroundColor('#F3F9F0')
        .onChange((v: string) => {
          this.plotTitle = v;
        })
        .margin({ top: 8 })

      Row() {
        Column() {
          Text(this.autoWater ? '自动浇水已开启' : '自动浇水已关闭')
            .fontSize(13)
            .fontWeight(FontWeight.Medium)
            .fontColor(COLORS172.textMain)
          Text(this.autoWater ? '每日 07:30 自动滴灌 300ml' : '需手动浇水并记录')
            .fontSize(10)
            .fontColor(COLORS172.textSub)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Row() {
          Text('')
            .width(18)
            .height(18)
            .borderRadius(9)
            .backgroundColor(this.autoWater ? COLORS172.sky : COLORS172.textHint)
            .margin({ left: this.autoWater ? 16 : 2 })
        }
        .width(36)
        .height(22)
        .borderRadius(11)
        .backgroundColor(this.autoWater ? '#E1F5FE' : '#F3F9F0')
        .justifyContent(FlexAlign.Start)
        .onClick(() => {
          this.autoWater = !this.autoWater;
        })
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#F3F9F0')
      .borderRadius(14)
      .margin({ top: 14 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 18 })
    .backgroundColor(COLORS172.cardBg)
  }
}

实验步骤与观察

EditPlotSheet172组件展示了一个关键的ArkTS生命周期回调:aboutToAppear。aboutToAppear是ArkTS组件的生命周期方法,在组件创建后、build方法执行前调用。在这个例子中,aboutToAppear将外部传入的plotName赋值给内部@State变量plotTitle,使得输入框的初始值与外部数据保持同步。

ArkTS组件的生命周期包括:aboutToAppear(创建后)、aboutToDisappear(销毁前)、onPageShow(页面显示)、onPageHide(页面隐藏)、onBackPress(返回键按下)。其中aboutToAppear是最常用的生命周期回调,用于初始化状态变量、发起网络请求等。

TextInput是ArkUI提供的文本输入组件,用于接收用户输入的文本。它通过placeholder参数设置占位提示文字,text参数绑定当前文本值,onChange回调在文本变化时触发。在这个例子中,TextInput的text参数绑定了this.plotTitle,但注意ArkTS的TextInput不会自动实现双向绑定——用户输入的变化需要通过onChange回调手动同步到@State变量。这种"手动同步"的模式虽然略显繁琐,但保证了数据流的单向性和可追踪性。

自定义开关组件是本实验的另一个技术亮点。整个开关由一个外层Row(宽36高22圆角11作为轨道背景)和一个内层Text('')空文本(宽18高18圆角9作为滑块)组成。当autoWater为true时,轨道背景色为浅蓝色(#E1F5FE),滑块背景色为天蓝色(COLORS172.sky),通过margin({ left: 16 })将滑块推到右侧(开启状态);当autoWater为false时,轨道背景为浅灰色,滑块背景为灰色,margin({ left: 2 })将滑块保持在左侧(关闭状态)。点击整个轨道区域时,onClick回调执行this.autoWater = !this.autoWater,切换开关状态。框架自动触发UI更新,滑块位置和颜色会平滑切换。

这种自定义开关组件展示了ArkTS的灵活性:通过基础的Row、Text组件和条件样式,可以构建出任何自定义的交互组件。虽然ArkUI提供了原生的Toggle组件,但在需要完全自定义视觉风格时,手动构建开关仍然是首选方案。

实验十二:删除确认弹窗与勾选状态管理

实验目的

观察确认对话框的交互设计,分析复选框的纯文本实现方式。

实验材料

@Component
struct FarmDeleteDialog172 {
  targetName: string = '';
  onCancel: () => void = () => {};
  onConfirm: () => void = () => {};
  @State clearData: boolean = false;

  build() {
    Column() {
      Column() {
        Text('🍂')
          .fontSize(38)
        Text('删除确认')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
          .margin({ top: 8 })

        Text('确定要移除「' + this.targetName + '」吗?')
          .fontSize(13)
          .fontColor(COLORS172.textSub)
          .margin({ top: 10 })

        Row() {
          Text(this.clearData ? '☑️' : '⬜')
            .fontSize(15)
          Text('同时清空该地块的生长记录')
            .fontSize(12)
            .fontColor(COLORS172.textMain)
            .margin({ left: 6 })
        }
        .width('100%')
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#FFFBF3')
        .borderRadius(10)
        .justifyContent(FlexAlign.Start)
        .margin({ top: 14 })
        .onClick(() => {
          this.clearData = !this.clearData;
        })

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

实验步骤与观察

FarmDeleteDialog172是一个通过bindContentCover绑定的全屏确认对话框。它接收targetName(目标名称)和两个回调函数onCancel、onConfirm。内部维护了一个@State clearData布尔变量,用于控制"同时清空生长记录"复选项的状态。

复选框的实现使用了Unicode字符的巧妙方式:通过this.clearData ? '☑️' : '⬜'在勾选时显示勾选框emoji,未勾选时显示空框emoji。这种"纯文本复选框"虽然不如原生Checkbox组件功能完善,但在视觉一致性方面有独特优势——emoji的跨平台渲染一致性较好,且无需引入额外的组件或图标资源。

使用emoji作为UI元素是移动端开发中的一种实用策略。在HarmonyOS中,emoji由系统字体统一渲染,无需额外的图标资源文件。但需要注意,不同设备上的emoji渲染风格可能略有差异,在需要像素级精确的UI中应谨慎使用。

对话框的结构遵循"图标→标题→描述→可选操作→按钮组"的经典模式。"取消"按钮使用轮廓样式(border边框+透明背景),"确认移除"按钮使用危险色实色背景(COLORS172.danger红色)。这种"弱化取消、强化确认"的视觉设计在危险操作确认场景中是标准实践,可以引导用户做出更谨慎的决定。

实验十三:收获市集横滑Banner与产品卡片

实验目的

观察横向滚动Banner的实现方式,分析产品卡片的交互设计。

实验材料

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

  @Builder
  topBanner() {
    Scroll() {
      Row() {
        ForEach([0, 1, 2], (i: number) => {
          Column() {
            Text('🧺')
              .fontSize(30)
            Text('吃不完 挂市集')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS172.white)
              .margin({ top: 6 })
            Text('社区邻居自提 · 2小时达')
              .fontSize(11)
              .fontColor('#FFFFFFB3')
              .margin({ top: 3 })
            Text('¥' + (9.9 + i * 4))
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS172.yellow)
              .margin({ top: 8 })
          }
          .width(190)
          .height(140)
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          .borderRadius(16)
          .linearGradient({
            angle: 135,
            colors: [[COLORS172.leafLight, 0.0], [COLORS172.leafDeep, 1.0]]
          })
          .margin({ right: 10 })
        }, (i: number) => 'banner' + i)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🧺 收获市集')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('上架我的收成')
            .fontSize(12)
            .fontColor(COLORS172.white)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(COLORS172.harvest)
            .onClick(() => {
              this.onSell('自种收成');
            })
        }
        .width('100%')
        .margin({ top: 12 })

        this.topBanner()

        Column() {
          ForEach(FARM_PRODUCTS172, (p: FarmProduct172) => {
            Column() {
              Row() {
                Text(p.icon)
                  .fontSize(30)
                Text('')
                  .layoutWeight(1)
                Text(p.tag)
                  .fontSize(9)
                  .fontColor(COLORS172.white)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor(COLORS172.leaf)
              }
              .width('100%')

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

              Text(p.weight + ' · ' + p.from)
                .fontSize(10)
                .fontColor(COLORS172.textSub)
                .width('100%')
                .margin({ top: 3 })

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

              Text('我也要卖')
                .fontSize(11)
                .fontColor(COLORS172.harvestDeep)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(11)
                .border({ width: 1, color: COLORS172.harvest })
                .margin({ top: 7 })
                .onClick(() => {
                  this.onSell(p.name);
                })
            }
            .padding(12)
            .backgroundColor(COLORS172.cardBg)
            .borderRadius(14)
            .alignItems(HorizontalAlign.Start)
            .onClick(() => {
              this.onDetail(p.name, p.icon);
            })
          }, (p: FarmProduct172) => p.name)
        }
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

实验步骤与观察

MarketTab172的topBanner方法展示了横向滚动Banner的标准实现模式。通过Scroll容器设置scrollable(ScrollDirection.Horizontal)实现水平滚动,内部使用ForEach渲染3个Banner卡片。每个Banner卡片宽度固定为190像素,高度140像素,使用linearGradient渐变背景。Banner之间的间距通过margin({ right: 10 })设置。ForEach的数据源是一个简单的数字数组[0, 1, 2],通过i * 4计算价格偏移量,展示不同价位的推荐内容。

在实际应用中,Banner通常配合Swiper组件使用,实现自动轮播效果。但本实验样本使用了Scroll手动滚动方式,更注重用户对浏览节奏的控制。Swiper是ArkUI提供的轮播容器组件,支持自动播放、循环切换、指示器显示等功能,适用于需要自动展示的场景。

产品卡片的设计遵循"图标+标签→标题→规格产地→价格+销量→操作按钮"的信息层级。每个信息行都通过Text('').layoutWeight(1)创建弹性间隔,实现信息的左右分布。"我也要卖"按钮使用了轮廓样式(border边框),与产品本身的丰收橙色边框形成视觉呼应。

整个页面的滚动容器使用scrollable(ScrollDirection.Vertical)垂直滚动,scrollBar(BarState.Off)隐藏滚动条。BarState是ArkUI定义的滚动条状态枚举,包含On(显示)、Off(隐藏)、Auto(自动显示隐藏)三个值。隐藏滚动条在沉浸式设计中是常见的选择,使界面更加简洁。

实验十四:农事百科页面与时间轴布局

实验目的

观察时间轴布局的实现方式,分析编号节点和连接线的渲染逻辑。

实验材料

@Component
struct FarmworkTab172 {
  onPlan: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('📖 农事百科')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('本周更新 6 篇')
            .fontSize(11)
            .fontColor(COLORS172.leaf)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          ForEach(FARMWORKS172, (f: FarmworkItem172) => {
            Row() {
              Text(f.icon)
                .fontSize(24)
                .width(54)
                .height(54)
                .textAlign(TextAlign.Center)
                .borderRadius(12)
                .backgroundColor('#EDF5EA')

              Column() {
                Text(f.title)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS172.textMain)
                Text(f.desc)
                  .fontSize(11)
                  .fontColor(COLORS172.textSub)
                  .margin({ top: 4 })
                Text(f.hot)
                  .fontSize(10)
                  .fontColor(COLORS172.harvestDeep)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })

              Text('照做')
                .fontSize(11)
                .fontColor(COLORS172.white)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(12)
                .backgroundColor(COLORS172.leaf)
                .onClick(() => {
                  this.onPlan(f.title);
                })
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS172.cardBg)
            .borderRadius(14)
            .margin({ top: 10 })
          }, (f: FarmworkItem172) => f.title)
        }
        .width('100%')

        Column() {
          ForEach(TODOS172, (td: TodoItem172, idx: number) => {
            Row() {
              Column() {
                Text((idx + 1) + '')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS172.white)
                  .width(22)
                  .height(22)
                  .textAlign(TextAlign.Center)
                  .borderRadius(11)
                  .backgroundColor(COLORS172.leaf)
                Text('')
                  .width(2)
                  .height(idx < TODOS172.length - 1 ? 30 : 0)
                  .backgroundColor(COLORS172.border)
              }
              .alignItems(HorizontalAlign.Center)

              Column() {
                Text(td.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(COLORS172.textMain)
                Text(td.time)
                  .fontSize(10)
                  .fontColor(COLORS172.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })
            }
            .width('100%')
            .padding({ top: 4, bottom: 4 })
          }, (td: TodoItem172) => 'cal' + td.title)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS172.cardBg)
        .borderRadius(14)
        .margin({ top: 10 })
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

实验步骤与观察

FarmworkTab172的农事历时间轴是一个精巧的布局实现。每个时间轴节点由一个Column容器组成,内部包含编号圆点(Text显示序号,22x22圆形背景)和连接线(Text('')宽2像素的竖线)。连接线的高度通过条件表达式动态计算:idx < TODOS172.length - 1 ? 30 : 0,即非最后一项时高度为30像素,最后一项时高度为0(不显示连接线)。这种"条件高度"的技巧实现了时间轴的连贯效果。

时间轴布局是移动端常见的信息展示模式,其核心是"节点+连接线"的垂直排列。在ArkTS中,连接线通常使用空Text或空Row组件,通过设置width和backgroundColor实现。通过条件判断最后一项不显示连接线,可以避免时间轴底部出现多余的竖线。

农事百科文章列表的每个条目包含了图标、标题、描述、热度和"照做"按钮。f.hot字段(如"12.8w 阅读")使用了丰收深橙色,与文章标题的深色形成对比,吸引用户关注热门内容。"照做"按钮点击后调用this.onPlan(f.title),将文章标题传递给父组件,父组件随后弹出播种计划弹窗。这种"从文章到行动"的设计闭环,体现了应用"学以致用"的产品理念。

实验十五:个人中心页面与统计面板

实验目的

观察个人中心的信息展示逻辑,分析多条件统计数据的渲染方式。

实验材料

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

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('👤 我的农场')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('⚙️')
            .fontSize(16)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('🧑‍🌾')
              .fontSize(38)
              .width(60)
              .height(60)
              .textAlign(TextAlign.Center)
              .borderRadius(30)
              .backgroundColor('#FFFFFF40')

            Column() {
              Text('都市农夫阿绿')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS172.white)
              Text('种植第 286 天 · Lv.8 绿手指')
                .fontSize(11)
                .fontColor('#FFFFFFB3')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 12 })

            Text('编辑')
              .fontSize(11)
              .fontColor(COLORS172.white)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .borderRadius(12)
              .border({ width: 1, color: '#FFFFFF99' })
          }
          .width('100%')

          Row() {
            ForEach(['在种', '累计收获', '自给率'], (s: string) => {
              Column() {
                if (s === '在种') {
                  Text('8 盆')
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS172.white)
                } else if (s === '累计收获') {
                  Text('36.2 斤')
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS172.yellow)
                } else {
                  Text('41%')
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS172.white)
                }
                Text(s)
                  .fontSize(10)
                  .fontColor('#FFFFFFB3')
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (s: string) => s)
          }
          .width('100%')
          .margin({ top: 14 })
          .padding({ top: 12, bottom: 12 })
          .borderRadius(12)
          .backgroundColor('#26FFFFFF')
        }
        .width('100%')
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [[COLORS172.leaf, 0.0], [COLORS172.leafDeep, 1.0]]
        })
        .margin({ top: 12 })

        Row() {
          Text('🧺 收获记录')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('清空记录')
            .fontSize(11)
            .fontColor(COLORS172.danger)
            .onClick(() => {
              this.onDelete('全部收获记录');
            })
        }
        .width('100%')
        .margin({ top: 14 })

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

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

              Text(o.status)
                .fontSize(11)
                .fontColor(o.status === '已发货' ? COLORS172.harvestDeep : COLORS172.leaf)
                .fontWeight(FontWeight.Medium)
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(12)
            .backgroundColor(COLORS172.cardBg)
            .margin({ top: 8 })
          }, (o: OrderItem172) => o.name)
        }
        .width('100%')
        .margin({ top: 2 })
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

实验步骤与观察

MineTab172的个人中心页面展示了用户信息卡片和统计数据。用户信息卡片使用了linearGradient从叶绿色到深叶绿色的渐变背景,内部包含用户头像(emoji)、用户名、等级信息和编辑按钮。backgroundColor('#FFFFFF40')中的"40"表示约25%透明度的白色,用于头像背景的半透明效果。border({ width: 1, color: '#FFFFFF99' })中的"99"表示约60%透明度的白色边框。

统计面板使用ForEach渲染三个统计项(在种、累计收获、自给率),每个项内部使用if/else if/else条件渲染显示不同的数值和颜色。"累计收获"项的数值使用黄色(COLORS172.yellow),与白色形成对比,突出显示核心数据。backgroundColor('#26FFFFFF')中的"26"表示约15%透明度的白色,在渐变背景上创建半透明统计面板的蒙层效果。

在渐变背景上叠加半透明白色面板是创建层次感的常用技巧。透明度值的选择需要平衡可读性和层次感:过低(如"10")时面板几乎不可见,过高(如"80")时面板会遮挡渐变背景。15%-30%的透明度(“26”-“4D”)是较常用的范围。

订单列表中,订单状态的文字颜色通过条件表达式动态设置:o.status === '已发货' ? COLORS172.harvestDeep : COLORS172.leaf。已发货状态使用丰收深橙色(突出当前进行中),其他状态使用叶绿色(表示已完成)。这种基于状态值的条件着色在列表项中非常实用,使每条记录的视觉表现与其业务状态直接关联。

实验十六:上架收成弹窗与价格选择

实验目的

观察SellDialog172全屏弹窗的表单设计,分析定价选择和发货方式切换的实现逻辑。

实验材料

@Component
struct SellDialog172 {
  productName: string = '';
  onCancel: () => void = () => {};
  onSubmit: () => void = () => {};
  @State weight: string = '500g';
  @State price: number = 9.9;
  @State selfPick: boolean = true;

  build() {
    Column() {
      Column() {
        Text('🧺')
          .fontSize(38)
        Text('上架收成')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
          .margin({ top: 8 })
        Text('「' + this.productName + '」将挂到收获市集')
          .fontSize(12)
          .fontColor(COLORS172.textSub)
          .margin({ top: 6 })

        Text('规格重量')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
          .width('100%')
          .margin({ top: 14 })
        Row() {
          ForEach(['250g', '500g', '1斤', '1kg'], (w: string) => {
            Text(w)
              .fontSize(12)
              .fontColor(this.weight === w ? COLORS172.white : COLORS172.textSub)
              .fontWeight(this.weight === w ? FontWeight.Bold : FontWeight.Normal)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor(this.weight === w ? COLORS172.leaf : '#F3F9F0')
              .margin({ right: 8 })
              .onClick(() => {
                this.weight = w;
              })
          }, (w: string) => w)
        }
        .width('100%')
        .justifyContent(FlexAlign.Start)
        .margin({ top: 8 })

        Row() {
          Column() {
            Text(this.selfPick ? '邻居自提' : '快递发货')
              .fontSize(13)
              .fontWeight(FontWeight.Medium)
              .fontColor(COLORS172.textMain)
            Text(this.selfPick ? '免运费 · 2 小时达' : '需自付生鲜邮费')
              .fontSize(10)
              .fontColor(COLORS172.textSub)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text(this.selfPick ? '✓ 开' : '✗ 关')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.selfPick ? COLORS172.leaf : COLORS172.textHint)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(12)
            .backgroundColor(this.selfPick ? '#E8F5E9' : '#F3F9F0')
            .onClick(() => {
              this.selfPick = !this.selfPick;
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor('#FFFBF3')
        .borderRadius(12)
        .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS172.textSub)
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .borderRadius(20)
            .border({ width: 1, color: COLORS172.border })
            .onClick(() => {
              this.onCancel();
            })
          Text('')
            .layoutWeight(1)
          Text('确认上架')
            .fontSize(14)
            .fontColor(COLORS172.white)
            .fontWeight(FontWeight.Bold)
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .borderRadius(20)
            .backgroundColor(COLORS172.harvestDeep)
            .onClick(() => {
              this.onSubmit();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('86%')
      .padding({ left: 18, right: 18, top: 22, bottom: 18 })
      .backgroundColor(COLORS172.cardBg)
      .borderRadius(18)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#00000000')
  }
}

实验步骤与观察

SellDialog172是一个上架收成的全屏弹窗,内部维护三个@State变量:weight(规格重量)、price(定价)和selfPick(是否自提)。这三个变量共同构成了上架商品的核心属性。

价格选择使用了ForEach渲染四个预设价格选项[6.9, 9.9, 12.9, 15.9]。每个选项的backgroundColor通过this.price === p ? COLORS172.harvest : '#FFF3E0'设置选中态,选中的价格使用丰收橙色实色背景,未选中的使用浅橙色背景。keyGenerator函数使用了'p' + p(字符串"p"拼接价格数值),避免了纯数字作为key可能带来的冲突。

ForEach的keyGenerator函数需要返回字符串类型的唯一键值。当数据源是数字数组时,直接使用数字作为key在某些场景下可能引发问题(如数字1和字符串"1"的冲突),最佳实践是添加前缀进行区分。

自提开关使用了文字+背景色的组合方式实现:开启时显示"✓ 开"和叶绿色背景,关闭时显示"✗ 关"和灰色背景。与EditPlotSheet172中的滑块式开关不同,这种"按钮式开关"更节省空间,适合在弹窗等紧凑布局中使用。两种开关的本质都是通过@State布尔变量控制样式和文字,点击时执行取反操作。

实验技术点对比

序号技术点类别使用位置作用说明实验观察结论
1@Entry装饰器FarmMain172标记页面入口组件整个应用唯一入口,注册到路由系统
2@Component装饰器所有struct声明自定义组件共12个子组件,均可被引用嵌套
3@State装饰器入口组件和弹窗组件响应式状态变量状态变化自动触发UI局部更新
4@Builder装饰器入口组件内定义可复用UI构建方法将复杂UI封装为方法,提高可读性
5interfaceTypeScript模块级别定义数据模型接口共7个接口,确保数据类型安全
6ForEachArkUI组件列表渲染根据数据列表循环渲染UI配合keyGenerator实现增量更新
7if/else条件渲染contentArea等条件分支渲染不同组件树页面切换使用条件渲染实现
8ScrollArkUI组件各页面容器可滚动容器支持水平和垂直滚动
9Column布局容器全局纵向线性布局子元素从上到下排列
10Row布局容器全局横向线性布局子元素从左到右排列
11Text基础组件全局文本显示兼做色块进度条等用途
12TextInput交互组件编辑弹窗文本输入需配合onChange手动同步状态
13bindSheet弹窗绑定入口组件build半模态底部弹窗2个sheet用于表单输入
14bindContentCover弹窗绑定入口组件build全模态覆盖弹窗3个cover用于确认和详情
15$$双向绑定bindSheet参数状态与弹窗显隐同步确保布尔状态精确控制弹窗
16linearGradient样式属性头部、卡片线性渐变背景angle控制方向,colors控制色阶
17layoutWeight布局属性全局按比例分配剩余空间常用于弹性间隔和等宽分布
18aboutToAppear生命周期编辑弹窗组件创建后初始化将外部参数同步到内部状态
19onClick事件属性全局点击事件处理回调函数中修改@State触发更新
20clip样式属性进度条、弹窗裁剪超出边界的内容配合borderRadius实现圆角裁剪

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 多多屋顶农场 · 阳台种菜指南
// 拼多多风格:森林绿 × 丰收橙,顶部胶囊分段 Tab

interface ColorPalette172 {
  leaf: string;
  leafDeep: string;
  leafLight: string;
  harvest: string;
  harvestDeep: string;
  soil: string;
  bg: string;
  cardBg: string;
  textMain: string;
  textSub: string;
  textHint: string;
  border: string;
  danger: string;
  white: string;
  sky: string;
  yellow: string;
}

const COLORS172: ColorPalette172 = {
  leaf: '#2E7D32',
  leafDeep: '#1B5E20',
  leafLight: '#A5D6A7',
  harvest: '#F57C00',
  harvestDeep: '#E65100',
  soil: '#795548',
  bg: '#F5FAF2',
  cardBg: '#FFFFFF',
  textMain: '#263A28',
  textSub: '#6B8E6E',
  textHint: '#A8C2AA',
  border: '#E3EFDF',
  danger: '#D84315',
  white: '#FFFFFF',
  sky: '#4FC3F7',
  yellow: '#FBC02D'
};

interface TabItem172 {
  key: string;
  icon: string;
  label: string;
}

const TABS172: TabItem172[] = [
  { key: 'season', icon: '🌱', label: '当季' },
  { key: 'garden', icon: '🪴', label: '我的菜园' },
  { key: 'market', icon: '🧺', label: '收获市集' },
  { key: 'supply', icon: '🛒', label: '农资' },
  { key: 'farmwork', icon: '📖', label: '农事' },
  { key: 'mine', icon: '👤', label: '我的' }
];

interface SeasonVeg172 {
  name: string;
  icon: string;
  score: number;
  days: number;
  level: number;
  price: number;
  tag: string;
}

const SEASON_VEGS172: SeasonVeg172[] = [
  { name: '奶油生菜', icon: '🥬', score: 96, days: 35, level: 1, price: 6.9, tag: '新手必种' },
  { name: '樱桃萝卜', icon: '🌶', score: 92, days: 30, level: 1, price: 5.9, tag: '30天收获' },
  { name: '小葱', icon: '🌿', score: 90, days: 25, level: 1, price: 4.9, tag: '割了又长' },
  { name: '空心菜', icon: '🥗', score: 88, days: 40, level: 1, price: 5.5, tag: '夏天神器' },
  { name: '水果黄瓜', icon: '🥒', score: 85, days: 55, level: 2, price: 9.9, tag: '爬藤高产' },
  { name: '圣女果', icon: '🍅', score: 82, days: 75, level: 2, price: 12.9, tag: '阳台顶流' },
  { name: '紫背天葵', icon: '🥬', score: 78, days: 50, level: 2, price: 8.9, tag: '观赏兼食' },
  { name: '羽衣甘蓝', icon: '🥬', score: 76, days: 60, level: 2, price: 10.9, tag: '轻食宠儿' },
  { name: '秋葵', icon: '🌱', score: 72, days: 65, level: 3, price: 11.9, tag: '南方适宜' },
  { name: '朝天椒', icon: '🌶', score: 70, days: 80, level: 3, price: 7.9, tag: '越冬多年' },
  { name: '草莓', icon: '🍓', score: 68, days: 90, level: 3, price: 15.9, tag: '甜到心坎' },
  { name: '拇指西瓜', icon: '🍉', score: 62, days: 85, level: 3, price: 14.9, tag: '猎奇首选' }
];

interface PlotItem172 {
  name: string;
  icon: string;
  progress: number;
  stage: string;
  days: number;
  area: string;
}

const PLOTS172: PlotItem172[] = [
  { name: '奶油生菜 · 一号盆', icon: '🥬', progress: 82, stage: '旺长期', days: 29, area: '长条盆 60cm' },
  { name: '樱桃萝卜 · 二号盆', icon: '🌶', progress: 55, stage: '肉质根膨大', days: 17, area: '深盆 25cm' },
  { name: '小葱 · 窗台槽', icon: '🌿', progress: 100, stage: '可采收', days: 25, area: '窗台槽 40cm' },
  { name: '水果黄瓜 · 爬架', icon: '🥒', progress: 46, stage: '开花期', days: 26, area: '大盆 35cm' },
  { name: '圣女果 · 二楼架', icon: '🍅', progress: 33, stage: '育苗期', days: 25, area: '营养钵 10cm' },
  { name: '空心菜 · 泡沫箱', icon: '🥗', progress: 91, stage: '可采收', days: 36, area: '泡沫箱 50cm' },
  { name: '草莓 · 塔式盆', icon: '🍓', progress: 12, stage: '缓苗期', days: 11, area: '塔盆 3层' },
  { name: '朝天椒 · 南向角', icon: '🌶', progress: 68, stage: '坐果期', days: 54, area: '方盆 30cm' }
];

interface TodoItem172 {
  icon: string;
  title: string;
  time: string;
  urgent: boolean;
}

const TODOS172: TodoItem172[] = [
  { icon: '💧', title: '小葱槽浇水 300ml', time: '今天 07:30', urgent: true },
  { icon: '🍃', title: '黄瓜打侧枝留主蔓', time: '今天 09:00', urgent: true },
  { icon: '🧴', title: '圣女果施稀释饼肥水', time: '今天 18:00', urgent: false },
  { icon: '🐛', title: '检查萝卜叶背蚜虫', time: '明天 08:00', urgent: false },
  { icon: '🔄', title: '生菜盆旋转受光面', time: '明天 17:00', urgent: false },
  { icon: '🌾', title: '空心菜第二茬采收', time: '后天 早晨', urgent: true }
];

interface FarmProduct172 {
  name: string;
  icon: string;
  weight: string;
  price: number;
  sold: number;
  from: string;
  tag: string;
}

const FARM_PRODUCTS172: FarmProduct172[] = [
  { name: '现摘奶油生菜', icon: '🥬', weight: '500g', price: 8.8, sold: 2300, from: '屋顶6号棚', tag: '今早现摘' },
  { name: '有机小葱', icon: '🌿', weight: '300g', price: 5.5, sold: 1900, from: '社区张姨', tag: '现挖现发' },
  { name: '水果黄瓜', icon: '🥒', weight: '1kg', price: 12.9, sold: 1500, from: '屋顶6号棚', tag: '带花现摘' },
  { name: '圣女果混装', icon: '🍅', weight: '1斤', price: 15.8, sold: 3100, from: '城东李叔', tag: '沙甜多汁' },
  { name: '拇指胡萝卜', icon: '🥕', weight: '500g', price: 11.5, sold: 860, from: '天台农场', tag: '阳台自种' },
  { name: '紫背天葵', icon: '🥬', weight: '250g', price: 9.9, sold: 640, from: '社区王伯', tag: '少见野菜' },
  { name: '盆栽草莓', icon: '🍓', weight: '3盆', price: 29.9, sold: 1200, from: '屋顶6号棚', tag: '带盆带果' },
  { name: '现掰秋葵', icon: '🌱', weight: '400g', price: 10.9, sold: 720, from: '城南赵姐', tag: '脆嫩无渣' },
  { name: '混合叶菜包', icon: '🥗', weight: '800g', price: 13.9, sold: 4100, from: '多农联供', tag: '一周 salad' },
  { name: '樱桃萝卜', icon: '🌶', weight: '500g', price: 9.5, sold: 980, from: '城东李叔', tag: '爽脆微辣' },
  { name: '朝天椒', icon: '🌶', weight: '200g', price: 7.9, sold: 1600, from: '天台农场', tag: '辣度爆表' },
  { name: '薄荷盆栽', icon: '🌿', weight: '1盆', price: 8.8, sold: 2400, from: '社区张姨', tag: '掐尖疯长' }
];

interface SupplyItem172 {
  name: string;
  icon: string;
  stock: number;
  price: number;
  unit: string;
  cat: string;
  off: number;
}

const SUPPLIES172: SupplyItem172[] = [
  { name: '通用营养土 20L', icon: '🪣', stock: 66, price: 19.9, unit: '袋', cat: '基质', off: 15 },
  { name: '椰糠砖 650g', icon: '🧱', stock: 40, price: 6.9, unit: '块', cat: '基质', off: 10 },
  { name: '蚯蚓粪有机肥 2kg', icon: '💩', stock: 28, price: 12.9, unit: '袋', cat: '肥料', off: 20 },
  { name: '缓释复合肥 500g', icon: '🧴', stock: 52, price: 15.9, unit: '瓶', cat: '肥料', off: 5 },
  { name: '长条种植盆 60cm', icon: '🪴', stock: 35, price: 22.9, unit: '个', cat: '盆器', off: 12 },
  { name: '加厚泡沫箱', icon: '📦', stock: 80, price: 9.9, unit: '个', cat: '盆器', off: 8 },
  { name: '自动浇水器', icon: '💧', stock: 18, price: 39.9, unit: '套', cat: '工具', off: 25 },
  { name: '园艺小铲三件套', icon: '🛠', stock: 45, price: 16.9, unit: '套', cat: '工具', off: 10 },
  { name: '爬藤支架 1.5m', icon: '🪜', stock: 22, price: 18.9, unit: '根', cat: '工具', off: 15 },
  { name: '黄板粘虫片 10张', icon: '🟡', stock: 90, price: 5.9, unit: '包', cat: '植保', off: 0 },
  { name: '苦楝油杀虫剂', icon: '🧪', stock: 31, price: 21.9, unit: '瓶', cat: '植保', off: 18 },
  { name: '遮阳网 3㎡', icon: '🕸', stock: 16, price: 11.9, unit: '张', cat: '植保', off: 6 }
];

interface FarmworkItem172 {
  title: string;
  icon: string;
  desc: string;
  hot: string;
}

const FARMWORKS172: FarmworkItem172[] = [
  { title: '立秋后种什么?', icon: '🍂', desc: '8月下旬播种清单与温差管理', hot: '12.8w 阅读' },
  { title: '阳台黄瓜整枝图解', icon: '🥒', desc: '一叶一瓜还是两叶一瓜?', hot: '9.6w 阅读' },
  { title: '蚜虫生物防治手册', icon: '🐛', desc: '不用药的物理+生物方案', hot: '8.1w 阅读' },
  { title: '堆肥箱入门', icon: '♻️', desc: '厨余变黑金的三步法', hot: '7.4w 阅读' },
  { title: '育苗块 vs 营养钵', icon: '🌱', desc: '移栽成活率对比实测', hot: '5.9w 阅读' },
  { title: '小空间立体种植', icon: '🏗', desc: '1㎡种出 20 盆的架构', hot: '5.2w 阅读' }
];

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

const ORDERS172: OrderItem172[] = [
  { name: '营养土 20L × 2', icon: '🪣', price: 39.8, status: '已发货', date: '08-23' },
  { name: '自动浇水器套装', icon: '💧', price: 39.9, status: '已完成', date: '08-19' },
  { name: '圣女果种子 × 3', icon: '🌱', price: 14.7, status: '已完成', date: '08-12' },
  { name: '塔式草莓盆 3层', icon: '🪴', price: 45.9, status: '待评价', date: '08-05' },
  { name: '苦楝油杀虫剂', icon: '🧪', price: 21.9, status: '已完成', date: '07-28' }
];

function levelStars172(n: number): string {
  let s = '';
  for (let i = 0; i < 5; i++) {
    s += (i < n) ? '★' : '☆';
  }
  return s;
}

function scoreC172(s: number): string {
  if (s >= 90) {
    return COLORS172.harvestDeep;
  }
  if (s >= 75) {
    return COLORS172.leaf;
  }
  return COLORS172.textSub;
}

function scoreBar172(s: number): string {
  return s + '%';
}

function plotC172(p: number): string {
  if (p >= 100) {
    return COLORS172.harvest;
  }
  if (p >= 60) {
    return COLORS172.leaf;
  }
  return COLORS172.sky;
}

function stageT172(p: number): string {
  if (p >= 100) {
    return '可采收';
  }
  if (p >= 60) {
    return '生长中';
  }
  return '育苗期';
}

function supplyOffC172(o: number): string {
  if (o >= 20) {
    return COLORS172.danger;
  }
  if (o >= 10) {
    return COLORS172.harvest;
  }
  return COLORS172.textSub;
}

function priceH172(p: number): string {
  let r = Math.round(p / 40 * 100);
  if (r < 12) {
    r = 12;
  }
  return r + '%';
}

@Entry
@Component
struct FarmMain172 {
  @State curTab: string = 'season';
  @State showPlan: boolean = false;
  @State showSell: boolean = false;
  @State showEditPlot: boolean = false;
  @State showDelete: boolean = false;
  @State showDetail: boolean = false;
  @State planName: string = '奶油生菜';
  @State sellName: string = '现摘奶油生菜';
  @State editPlotName: string = '奶油生菜 · 一号盆';
  @State deleteName: string = '小葱 · 窗台槽';
  @State detailName: string = '奶油生菜';
  @State detailIcon: string = '🥬';

  @Builder
  pageHeader() {
    Column() {
      Row() {
        Text('🌾 立秋 · 第2候')
          .fontSize(12)
          .fontColor(COLORS172.white)
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .borderRadius(10)
          .backgroundColor('#33555555')
        Text('')
          .layoutWeight(1)
        Text('多云 26~33℃')
          .fontSize(12)
          .fontColor(COLORS172.white)
        Text('☁️')
          .fontSize(14)
          .margin({ left: 4 })
      }
      .width('100%')

      Row() {
        Column() {
          Text('多多屋顶农场')
            .fontSize(21)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.white)
          Text('阳台一米 · 自给自足')
            .fontSize(11)
            .fontColor('#FFFFFFB3')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Row() {
          Text('🎟')
            .fontSize(17)
          Text('农资券')
            .fontSize(12)
            .fontColor(COLORS172.leafDeep)
            .fontWeight(FontWeight.Medium)
        }
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(COLORS172.white)
        .margin({ right: 8 })

        Text('🔔')
          .fontSize(19)
          .width(36)
          .height(36)
          .textAlign(TextAlign.Center)
          .borderRadius(18)
          .backgroundColor(COLORS172.white)
      }
      .width('100%')
      .margin({ top: 10 })

      Row() {
        Text('🔍')
          .fontSize(15)
          .margin({ right: 6 })
        Text('搜菜种 / 营养土 / 支架')
          .fontSize(13)
          .fontColor(COLORS172.textHint)
        Text('')
          .layoutWeight(1)
        Text('客服')
          .fontSize(12)
          .fontColor(COLORS172.leaf)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .borderRadius(12)
          .backgroundColor('#E8F5E9')
      }
      .width('100%')
      .padding({ left: 14, right: 10, top: 9, bottom: 9 })
      .borderRadius(20)
      .backgroundColor(COLORS172.white)
      .margin({ top: 12 })
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 10, bottom: 14 })
    .linearGradient({
      angle: 0,
      colors: [[COLORS172.leaf, 0.0], [COLORS172.leafDeep, 1.0]]
    })
  }

  @Builder
  tabBarRow() {
    Scroll() {
      Row() {
        ForEach(TABS172, (t: TabItem172) => {
          Row() {
            Text(t.icon)
              .fontSize(15)
            Text(t.label)
              .fontSize(13)
              .fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.curTab === t.key ? COLORS172.leafDeep : COLORS172.textSub)
              .margin({ left: 4 })
          }
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .borderRadius(20)
          .backgroundColor(this.curTab === t.key ? '#DCEFD9' : '#FFFFFF')
          .margin({ right: 8 })
          .onClick(() => {
            this.curTab = t.key;
          })
        }, (t: TabItem172) => t.key)
      }
      .width('100%')
      .padding({ left: 12, right: 4 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ top: 10 })
  }

  @Builder
  planSheet() {
    PlanSheet172({
      vegName: this.planName,
      onClose: (): void => {
        this.showPlan = false;
      }
    })
  }

  @Builder
  sellDialog() {
    SellDialog172({
      productName: this.sellName,
      onCancel: (): void => {
        this.showSell = false;
      },
      onSubmit: (): void => {
        this.showSell = false;
      }
    })
  }

  @Builder
  editPlotSheet() {
    EditPlotSheet172({
      plotName: this.editPlotName,
      onClose: (): void => {
        this.showEditPlot = false;
      }
    })
  }

  @Builder
  deleteDialog() {
    FarmDeleteDialog172({
      targetName: this.deleteName,
      onCancel: (): void => {
        this.showDelete = false;
      },
      onConfirm: (): void => {
        this.showDelete = false;
      }
    })
  }

  @Builder
  detailDialog() {
    VegDetailDialog172({
      vegName: this.detailName,
      vegIcon: this.detailIcon,
      onClose: (): void => {
        this.showDetail = false;
      }
    })
  }

  @Builder
  contentArea() {
    Column() {
      if (this.curTab === 'season') {
        SeasonTab172({
          onPlan: (n: string): void => {
            this.planName = n;
            this.showPlan = true;
          },
          onDetail: (n: string, ic: string): void => {
            this.detailName = n;
            this.detailIcon = ic;
            this.showDetail = true;
          }
        })
      } else if (this.curTab === 'garden') {
        GardenTab172({
          onEdit: (n: string): void => {
            this.editPlotName = n;
            this.showEditPlot = true;
          },
          onDelete: (n: string): void => {
            this.deleteName = n;
            this.showDelete = true;
          }
        })
      } else if (this.curTab === 'market') {
        MarketTab172({
          onSell: (n: string): void => {
            this.sellName = n;
            this.showSell = true;
          },
          onDetail: (n: string, ic: string): void => {
            this.detailName = n;
            this.detailIcon = ic;
            this.showDetail = true;
          }
        })
      } else if (this.curTab === 'supply') {
        SupplyTab172({
          onDetail: (n: string, ic: string): void => {
            this.detailName = n;
            this.detailIcon = ic;
            this.showDetail = true;
          }
        })
      } else if (this.curTab === 'farmwork') {
        FarmworkTab172({
          onPlan: (n: string): void => {
            this.planName = n;
            this.showPlan = true;
          }
        })
      } else {
        MineTab172({
          onDetail: (n: string, ic: string): void => {
            this.detailName = n;
            this.detailIcon = ic;
            this.showDetail = true;
          },
          onDelete: (n: string): void => {
            this.deleteName = n;
            this.showDelete = true;
          }
        })
      }
    }
    .width('100%')
    .layoutWeight(1)
  }

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

      this.tabBarRow()

      this.contentArea()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS172.bg)
    .bindSheet($$this.showPlan, this.planSheet(), {
      height: 580,
      dragBar: true,
      showClose: true,
      backgroundColor: COLORS172.cardBg
    })
    .bindSheet($$this.showEditPlot, this.editPlotSheet(), {
      height: 500,
      dragBar: true,
      showClose: true,
      backgroundColor: COLORS172.cardBg
    })
    .bindContentCover($$this.showSell, this.sellDialog(), {
      backgroundColor: '#00000000'
    })
    .bindContentCover($$this.showDelete, this.deleteDialog(), {
      backgroundColor: '#00000000'
    })
    .bindContentCover($$this.showDetail, this.detailDialog(), {
      backgroundColor: '#00000000'
    })
  }
}

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

  @Builder
  scoreCard() {
    Column() {
      Row() {
        Text('📈 立秋适种指数')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
        Text('')
          .layoutWeight(1)
        Text('按本地气候排序')
          .fontSize(10)
          .fontColor(COLORS172.textHint)
      }
      .width('100%')

      Column() {
        ForEach(SEASON_VEGS172, (v: SeasonVeg172, idx: number) => {
          if (idx < 6) {
            Row() {
              Text(v.icon)
                .fontSize(20)
              Text(v.name)
                .fontSize(12)
                .fontColor(COLORS172.textMain)
                .width(86)

              Row() {
                Text('')
                  .width(scoreBar172(v.score))
                  .height(10)
                  .borderRadius(5)
                  .backgroundColor(scoreC172(v.score))
              }
              .width('100%')
              .layoutWeight(1)
              .height(10)
              .borderRadius(5)
              .backgroundColor('#EDF5EA')
              .justifyContent(FlexAlign.Start)
              .clip(true)

              Text(v.score + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(scoreC172(v.score))
                .width(30)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7, bottom: 7 })
          }
        }, (v: SeasonVeg172) => v.name)
      }
      .width('100%')
      .margin({ top: 4 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS172.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('🥬 秋播黄金期')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS172.white)
            Text('距最佳播期结束还有 18 天')
              .fontSize(11)
              .fontColor('#FFFFFFCC')
              .margin({ top: 4 })
            Row() {
              Text('查看播期表')
                .fontSize(12)
                .fontColor(COLORS172.leafDeep)
                .fontWeight(FontWeight.Bold)
                .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                .borderRadius(14)
                .backgroundColor('#FFFFFF')
                .margin({ top: 10 })
            }
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('🍂')
            .fontSize(52)
            .margin({ right: 8 })
        }
        .width('100%')
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [[COLORS172.harvest, 0.0], [COLORS172.harvestDeep, 1.0]]
        })
        .margin({ top: 12 })

        this.scoreCard()

        Row() {
          Text('🌱 当季可种蔬菜')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('共 12 种')
            .fontSize(11)
            .fontColor(COLORS172.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(SEASON_VEGS172, (v: SeasonVeg172) => {
            Column() {
              Row() {
                Text(v.icon)
                  .fontSize(32)
                Text('')
                  .layoutWeight(1)
                Text(v.tag)
                  .fontSize(9)
                  .fontColor(COLORS172.white)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor(COLORS172.harvest)
              }
              .width('100%')

              Text(v.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS172.textMain)
                .width('100%')
                .margin({ top: 8 })

              Text(levelStars172(v.level))
                .fontSize(11)
                .fontColor(COLORS172.leaf)
                .width('100%')
                .margin({ top: 3 })

              Row() {
                Text(v.days + '天收')
                  .fontSize(10)
                  .fontColor(COLORS172.textSub)
                Text('')
                  .layoutWeight(1)
                Text('¥' + v.price)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS172.harvestDeep)
              }
              .width('100%')
              .margin({ top: 6 })

              Text('做播种计划')
                .fontSize(12)
                .fontColor(COLORS172.white)
                .fontWeight(FontWeight.Bold)
                .padding({ left: 16, right: 16, top: 6, bottom: 6 })
                .borderRadius(13)
                .backgroundColor(COLORS172.leaf)
                .margin({ top: 8 })
                .onClick(() => {
                  this.onPlan(v.name);
                })
            }
            .padding(12)
            .backgroundColor(COLORS172.cardBg)
            .borderRadius(14)
            .alignItems(HorizontalAlign.Start)
            .onClick(() => {
              this.onDetail(v.name, v.icon);
            })
          }, (v: SeasonVeg172) => v.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 GardenTab172 {
  onEdit: (n: string) => void = () => {};
  onDelete: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🪴 我的菜园')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('8 盆在种 · 2 盆可收')
            .fontSize(11)
            .fontColor(COLORS172.harvestDeep)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          ForEach(PLOTS172, (p: PlotItem172) => {
            Column() {
              Row() {
                Text(p.icon)
                  .fontSize(24)
                  .width(46)
                  .height(46)
                  .textAlign(TextAlign.Center)
                  .borderRadius(12)
                  .backgroundColor('#EDF5EA')

                Column() {
                  Text(p.name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS172.textMain)
                  Text(p.area + ' · 已种 ' + p.days + ' 天')
                    .fontSize(10)
                    .fontColor(COLORS172.textSub)
                    .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })

                Text(p.stage)
                  .fontSize(11)
                  .fontColor(COLORS172.white)
                  .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                  .borderRadius(10)
                  .backgroundColor(plotC172(p.progress))
              }
              .width('100%')

              Row() {
                Text('')
                  .width(p.progress + '%')
                  .height(8)
                  .borderRadius(4)
                  .backgroundColor(plotC172(p.progress))
              }
              .width('100%')
              .height(8)
              .borderRadius(4)
              .backgroundColor('#EDF5EA')
              .justifyContent(FlexAlign.Start)
              .clip(true)
              .margin({ top: 10 })

              Row() {
                Text('生长进度 ' + p.progress + '%')
                  .fontSize(10)
                  .fontColor(COLORS172.textHint)
                Text('')
                  .layoutWeight(1)
                Text(stageT172(p.progress))
                  .fontSize(10)
                  .fontColor(plotC172(p.progress))
                Text('编辑')
                  .fontSize(11)
                  .fontColor(COLORS172.leaf)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .borderRadius(10)
                  .border({ width: 1, color: COLORS172.leaf })
                  .margin({ left: 10 })
                  .onClick(() => {
                    this.onEdit(p.name);
                  })
                Text('除盆')
                  .fontSize(11)
                  .fontColor(COLORS172.danger)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .borderRadius(10)
                  .border({ width: 1, color: COLORS172.danger })
                  .margin({ left: 6 })
                  .onClick(() => {
                    this.onDelete(p.name);
                  })
              }
              .width('100%')
              .margin({ top: 8 })
            }
            .width('100%')
            .padding(13)
            .backgroundColor(COLORS172.cardBg)
            .borderRadius(14)
            .margin({ top: 10 })
          }, (p: PlotItem172) => p.name)
        }
        .width('100%')

        Row() {
          Text('📋 今日照护')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('3 项紧急')
            .fontSize(11)
            .fontColor(COLORS172.danger)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(TODOS172, (td: TodoItem172) => {
            Row() {
              Text(td.icon)
                .fontSize(17)
              Column() {
                Text(td.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(COLORS172.textMain)
                Text(td.time)
                  .fontSize(10)
                  .fontColor(td.urgent ? COLORS172.danger : COLORS172.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })

              Text(td.urgent ? '马上' : '稍后')
                .fontSize(10)
                .fontColor(COLORS172.white)
                .padding({ left: 9, right: 9, top: 4, bottom: 4 })
                .borderRadius(9)
                .backgroundColor(td.urgent ? COLORS172.danger : COLORS172.textHint)
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(12)
            .backgroundColor('#FFFBF3')
            .margin({ top: 7 })
          }, (td: TodoItem172) => td.title)
        }
        .width('100%')
        .margin({ top: 8 })

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

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

  @Builder
  topBanner() {
    Scroll() {
      Row() {
        ForEach([0, 1, 2], (i: number) => {
          Column() {
            Text('🧺')
              .fontSize(30)
            Text('吃不完 挂市集')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS172.white)
              .margin({ top: 6 })
            Text('社区邻居自提 · 2小时达')
              .fontSize(11)
              .fontColor('#FFFFFFB3')
              .margin({ top: 3 })
            Text('¥' + (9.9 + i * 4))
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS172.yellow)
              .margin({ top: 8 })
          }
          .width(190)
          .height(140)
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          .borderRadius(16)
          .linearGradient({
            angle: 135,
            colors: [[COLORS172.leafLight, 0.0], [COLORS172.leafDeep, 1.0]]
          })
          .margin({ right: 10 })
        }, (i: number) => 'banner' + i)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🧺 收获市集')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('上架我的收成')
            .fontSize(12)
            .fontColor(COLORS172.white)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(COLORS172.harvest)
            .onClick(() => {
              this.onSell('自种收成');
            })
        }
        .width('100%')
        .margin({ top: 12 })

        this.topBanner()

        Row() {
          Text('🌾 邻居在卖')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('3km 内自提免运费')
            .fontSize(10)
            .fontColor(COLORS172.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(FARM_PRODUCTS172, (p: FarmProduct172) => {
            Column() {
              Row() {
                Text(p.icon)
                  .fontSize(30)
                Text('')
                  .layoutWeight(1)
                Text(p.tag)
                  .fontSize(9)
                  .fontColor(COLORS172.white)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor(COLORS172.leaf)
              }
              .width('100%')

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

              Text(p.weight + ' · ' + p.from)
                .fontSize(10)
                .fontColor(COLORS172.textSub)
                .width('100%')
                .margin({ top: 3 })

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

              Text('我也要卖')
                .fontSize(11)
                .fontColor(COLORS172.harvestDeep)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(11)
                .border({ width: 1, color: COLORS172.harvest })
                .margin({ top: 7 })
                .onClick(() => {
                  this.onSell(p.name);
                })
            }
            .padding(12)
            .backgroundColor(COLORS172.cardBg)
            .borderRadius(14)
            .alignItems(HorizontalAlign.Start)
            .onClick(() => {
              this.onDetail(p.name, p.icon);
            })
          }, (p: FarmProduct172) => p.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 SupplyTab172 {
  @State selCat: string = '全部';
  onDetail: (n: string, ic: string) => void = () => {};

  @Builder
  catRow() {
    Scroll() {
      Row() {
        ForEach(['全部', '基质', '肥料', '盆器', '工具', '植保'], (c: string) => {
          Text(c)
            .fontSize(12)
            .fontColor(this.selCat === c ? COLORS172.white : COLORS172.textSub)
            .fontWeight(this.selCat === c ? FontWeight.Bold : FontWeight.Normal)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(15)
            .backgroundColor(this.selCat === c ? COLORS172.leaf : '#FFFFFF')
            .margin({ right: 8 })
            .onClick(() => {
              this.selCat = c;
            })
        }, (c: string) => c)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ top: 12 })
  }

  @Builder
  priceCard() {
    Column() {
      Row() {
        Text('📊 热销农资价格')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS172.textMain)
        Text('')
          .layoutWeight(1)
        Text('单位 ¥')
          .fontSize(10)
          .fontColor(COLORS172.textHint)
      }
      .width('100%')

      Row() {
        ForEach(SUPPLIES172, (s: SupplyItem172, idx: number) => {
          if (idx < 6) {
            Column() {
              Column() {
                Text('')
                  .width('100%')
                  .height(priceH172(s.price))
                  .borderRadius({ topLeft: 5, topRight: 5 })
                  .backgroundColor(s.price >= 20 ? COLORS172.harvest : COLORS172.leaf)
              }
              .width('72%')
              .height(110)
              .justifyContent(FlexAlign.End)
              .backgroundColor('#EDF5EA')
              .borderRadius({ topLeft: 5, topRight: 5 })
              .clip(true)

              Text('¥' + s.price)
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS172.textMain)
                .margin({ top: 4 })
              Text(s.name.slice(0, 3))
                .fontSize(9)
                .fontColor(COLORS172.textSub)
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
        }, (s: SupplyItem172) => 'price' + s.name)
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS172.cardBg)
    .borderRadius(16)
    .margin({ top: 12 })
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🛒 农资补给')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('满 88 包邮')
            .fontSize(11)
            .fontColor(COLORS172.harvestDeep)
        }
        .width('100%')
        .margin({ top: 12 })

        this.catRow()

        this.priceCard()

        Row() {
          Text('🧰 全部农资')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS172.textMain)
          Text('')
            .layoutWeight(1)
          Text('共 12 件')
            .fontSize(11)
            .fontColor(COLORS172.textHint)
        }
        .width('100%')
        .margin({ top: 14 })

        Column() {
          ForEach(SUPPLIES172, (s: SupplyItem172) => {
            Row() {
              Text(s.icon)
                .fontSize(22)
                .width(48)
                .height(48)
                .textAlign(TextAlign.Center)
                .borderRadius(12)
                .backgroundColor('#EDF5EA')

              Column() {
                Row() {
                  Text(s.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS172.textMain)
                  Text(s.cat)
                    .fontSize(9)
                    .fontColor(COLORS172.leaf)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(7)
                    .backgroundColor('#E8F5E9')
                    .margin({ left: 6 })
                }

                Row() {
                  Text('¥' + s.price + '/' + s.unit)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS172.harvestDeep)
                  Text('库存 ' + s.stock)
                    .fontSize(10)
                    .fontColor(COLORS172.textHint)
                    .margin({ left: 10 })
      
}


总结

在这里插入图片描述

经过十六组实验的系统化分析,本实验报告完整解构了基于HarmonyOS API 24开发的阳台种菜指南应用的技术架构。从色彩体系的interface定义到纯函数的阈值映射,从入口组件的@State状态集到子组件的回调通信,从bindSheet半模态弹窗到bindContentCover全模态弹窗,每一层技术实现都体现了ArkTS声明式UI的设计哲学——开发者只需声明数据与视图的映射关系,框架自动处理渲染、更新、动画等底层逻辑。

在状态管理层面,应用采用了"状态提升"策略,将所有关键状态集中在根组件管理,子组件通过回调函数与父组件通信。这种模式虽然在状态量增多时可能面临"prop drilling"(逐层传参)的挑战,但在中型应用中是最简洁、最可维护的方案。12个@State变量覆盖了Tab切换、弹窗显隐、弹窗上下文三大类状态,每个状态变量都有明确的职责边界,避免了状态混乱。子组件内部也有自己的@State变量(如分类筛选、选项选择、开关状态),这些局部状态的更新不会影响外部页面,实现了状态的隔离管理。

在UI渲染层面,ForEach和if/else是两大核心渲染控制机制。ForEach用于列表数据的循环渲染,通过keyGenerator实现高效增量更新;if/else用于页面级切换,实现"非此即彼"的组件树替换。两者的区别在于更新粒度:ForEach是"同结构不同数据"的增量更新,if/else是"不同结构"的整体替换。应用在Tab切换中使用if/else,在列表渲染中使用ForEach,在列表项内部又使用if进行数量过滤,三者组合形成了完整的渲染控制体系。

在交互设计层面,bindSheet和bindContentCover提供了两种弹窗范式。bindSheet的半模态底部抽屉适用于表单输入和选择操作,用户可以同时看到背景内容,交互压力较低;bindContentCover的全模态居中弹窗适用于详情展示和危险操作确认,要求用户全神贯注地进行交互。两种弹窗的显隐都通过$$双向绑定与@State布尔变量关联,实现了"改状态即弹窗"的简洁控制。弹窗内部组件通过参数接收上下文数据和回调函数,完全解耦于父组件,可以独立开发和维护。

在数据流层面,整个应用的数据遵循"父→子"的单向流动原则。父组件通过参数传递将数据注入子组件,子组件通过回调函数将用户操作结果回传给父组件。父组件更新@State后,框架自动将新数据传递给子组件,触发子组件重新渲染。这种单向数据流保证了数据变化的可追踪性,任何UI变化都可以追溯到某个@State变量的赋值操作。纯函数在数据流中扮演"转换器"角色,将原始数据转换为UI需要的格式(如颜色值、百分比字符串等),它们不改变任何状态,是函数式编程思想在ArkTS中的具体体现。

Logo

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

更多推荐