引言

在这里插入图片描述

随着全屋智能概念在消费市场的快速渗透,智能音箱、扫地机器人、智能灯具、智能门锁、智能摄像头等品类已经从单一硬件进化为彼此联动的智能家居生态。如何在一个移动端应用中同时承载海量设备数据展示、多维参数对比、以旧换新估价、延保服务购买、消息中心推送等复杂业务,对前端框架的声明式UI能力和状态管理提出了极高的要求。HarmonyOS 6.1.1 所搭载的 ArkTS 声明式开发范式,以其强类型的接口定义、@State 驱动的响应式渲染、@Builder 装饰器的组件化拆分能力,为这类重数据驱动的电商型应用提供了理想的工程底座。

本应用以"智能家电数码港"为核心业务场景,构建了一个涵盖七大功能 Tab 的完整交易入口。应用在数据层定义了 DigitalItem、SpeakerItem、RobotItem、LightItem、TradeItem、MessageItem 六组强类型接口,分别对应数码集市商品、音箱参数、机器人参数、灯具参数、以旧换新记录和站内消息。这些接口不仅仅是数据容器,更是整个页面渲染逻辑的类型安全基石——ArkTS 编译器会在构建期检查每个字段访问的类型合法性,从源头杜绝了弱类型语言常见的运行时属性未定义问题。

在架构设计上,应用采用 Stack 作为根容器,将主页面 Column 与多个弹窗模态(modalPublish、modalTrade、modalWarranty、modalDelete、modalDetail)叠加在同一视觉层级。主页面内部又通过 headerBar、Scroll 内容区、bottomBar 三段式纵向布局,实现了顶部导航栏、可滚动内容区、底部 Tab 栏的经典移动电商结构。内容区根据 currentTab 状态值动态切换七个不同的 @Builder 视图,这种基于状态驱动的条件渲染是 ArkUI 声明式范式的核心体现,使得视图与数据之间建立了单向数据流的响应关系,任何状态变更都会自动触发对应视图的精确刷新,开发者无需手动操作 DOM 节点。

此外,应用在交互细节上大量运用了 scale 配合 animation 的循环缩放动画来引导用户视觉焦点,例如换新入口卡片的 1.05 倍缩放、补贴金额的 1.08 倍缩放、未读消息红点的 1.1 倍脉冲,这些动画均设置 iterations: -1 实现无限循环,curve 选用 EaseInOut 保证缓动曲线的对称性。这些细节虽然微小,但正是它们构成了应用在鸿蒙设备上的高级感与活力感,也是 ArkTS 动画系统能力的集中展示。

逐段代码分析

一、数据接口与业务模型定义

在这里插入图片描述

应用首先通过 interface 关键字定义了六组强类型数据结构,这是整个应用的类型安全基石。每个接口都精确描述了对应业务实体的字段集合,字段类型覆盖了 string、number 和联合类型 null,使得数据模型在编译期即可被完全校验。

interface DigitalItem {
  name: string
  price: number
  sold: number
  quality: number
  category: string
  tag: string
}

interface SpeakerItem {
  name: string
  watt: number
  channels: string
  brand: string
  price: number
}

interface RobotItem {
  name: string
  suction: number
  battery: number
  price: number
  mapNav: string
}

interface LightItem {
  name: string
  lumen: number
  colorTemp: string
  price: number
  rooms: number
}

interface TradeItem {
  name: string
  brand: string
  oldVal: number
  subsidy: number
  year: number
}

interface MessageItem {
  title: string
  content: string
  time: string
  unread: number
}

DigitalItem 作为数码集市的核心商品模型,包含名称、价格、销量、口碑评分、品类分类和营销标签六个字段,覆盖了商品展示所需的全部维度。SpeakerItem 专门为音箱品类建模,额外引入了 watt 功率、channels 声道和 brand 品牌字段,用于音箱功率榜的可视化。RobotItem 聚焦扫地机器人的 suction 吸力、battery 续航和 mapNav 导航方式,这些是消费者选购机器人的核心参数。LightItem 则定义了 lumen 流明、colorTemp 色温和 rooms 适配房间数,支撑灯具亮度对比图。TradeItem 记录以旧换新的品牌、旧机估值、补贴金额和使用年限,用于计算折旧进度。MessageItem 描述站内消息的标题、内容、时间和未读数,驱动消息中心的红点徽标渲染。

这种将不同品类的参数模型分离为独立接口的设计方式,避免了单一巨型接口的字段冗余问题。例如音箱不需要 suction 字段,机器人不需要 lumen 字段,如果将它们合并为一个泛化的 ProductItem 接口,就会产生大量未使用的可选字段,破坏类型系统的严密性。分离接口后,每个 ForEach 循环都能获得精确的类型推断,编译器在访问 s.watt 或 r.suction 时能保证字段存在且类型正确。

二、组件状态与静态数据声明

在这里插入图片描述

主组件 SmartHomePortPage 通过 @Entry 和 @Component 装饰器声明为应用的入口页面组件。组件内部使用 @State 装饰器声明了一系列响应式状态变量,这些变量的任何变更都会触发关联视图的自动刷新。同时,组件还以 private 成员的形式持有大量静态数据数组,这些数据在组件生命周期内保持不变,构成了应用的商品数据库。

@Entry
@Component
struct SmartHomePortPage {
  @State currentTab: number = 0
  @State showPublish: boolean = false
  @State showTrade: boolean = false
  @State showWarranty: boolean = false
  @State showDelete: boolean = false
  @State showDetail: boolean = false
  @State selectedItem: DigitalItem | null = null
  @State selectedCategory: number = 0
  @State selectedBrand: number = 0
  @State selectedCond: number = 0
  @State selectedYears: number = 0
  @State qty: number = 1
  @State inputName: string = ''

  private tabs: string[] = ['数码集市', '智能音箱', '扫地机器人', '智能灯具', '以旧换新', '消息', '我的']
  private categories: string[] = ['音箱', '机器人', '灯具', '门锁', '摄像头', '插座']
  private brands: string[] = ['小米', '华为', '京东京造', '海尔', '美的', '云鲸']
  private conds: string[] = ['九成新以上', '八成新', '七成新', '有明显使用痕迹']
  private warrantyOptions: string[] = ['1 年延保', '2 年延保', '3 年延保', '碎屏保']

状态变量的设计体现了应用的核心交互逻辑。currentTab 控制七个 Tab 视图的切换,是整个页面导航的中枢。五个 show 开头的布尔状态分别控制五种弹窗模态的显隐,当用户触发对应操作时设为 true,弹窗渲染到 Stack 叠加层;关闭时设为 false,弹窗从视图树中移除。selectedItem 使用联合类型 DigitalItem | null,初始为 null 表示无选中商品,用户点击商品卡片后被赋值为对应 DigitalItem 对象,驱动详情弹窗的内容渲染。selectedCategory、selectedBrand、selectedCond、selectedYears 四个索引状态分别记录用户在弹窗中选择的品类、品牌、成色和延保方案,qty 记录购买数量,inputName 记录用户在发布二手设备时输入的名称。

静态数据数组则按照业务域划分为配置数据和商品数据两类。tabs、categories、brands、conds、warrantyOptions 是配置型数据,定义了 Tab 标签、品类筛选标签、品牌列表、成色等级和延保方案选项,它们在多个弹窗中被复用。商品数据数组 digitals、speakers、robots、lights、trades、messages 各自持有十到二十四条预置记录,模拟了真实电商场景下的商品列表。这些数据虽然是硬编码的,但其结构和字段密度已经足够支撑完整的业务逻辑演示。

三、商品数据集的构建

在这里插入图片描述

digitals 数组是数码集市 Tab 的数据源,包含 24 条商品记录,覆盖了音箱、机器人、灯具、门锁、摄像头、插座六个品类。每条记录都携带完整的展示参数,包括营销标签和口碑评分,为卡片渲染提供了丰富的信息维度。

private digitals: DigitalItem[] = [
  { name: '智能屏音箱 Pro 8 英寸', price: 599, sold: 2143, quality: 95, category: '音箱', tag: '爆款' },
  { name: '智能音箱 mini 电池版', price: 149, sold: 6824, quality: 92, category: '音箱', tag: '实惠' },
  { name: '回音壁音箱 3.1 声道', price: 1299, sold: 876, quality: 96, category: '音箱', tag: '推荐' },
  { name: '无线麦克风音箱 K 歌', price: 399, sold: 1320, quality: 90, category: '音箱', tag: '热卖' },
  { name: '扫地机器人 S7 激光导航', price: 1899, sold: 1567, quality: 97, category: '机器人', tag: '旗舰' },
  { name: '扫拖一体机器人 X10', price: 2799, sold: 982, quality: 96, category: '机器人', tag: '新品' },
  { name: '自动集尘扫地机 T8', price: 1599, sold: 1745, quality: 93, category: '机器人', tag: '爆款' },
  { name: '擦窗机器人 方形双吸', price: 1199, sold: 645, quality: 92, category: '机器人', tag: '推荐' },
  { name: '智能吸顶灯 客厅 90cm', price: 499, sold: 2109, quality: 94, category: '灯具', tag: '爆款' },
  { name: '智能台灯 护眼国AA', price: 259, sold: 4532, quality: 95, category: '灯具', tag: '热卖' },
  { name: '智能灯带 5m RGB 联动', price: 89, sold: 5743, quality: 90, category: '灯具', tag: '实惠' },
  { name: '人脸识别智能门锁 3D', price: 1899, sold: 1105, quality: 96, category: '门锁', tag: '爆款' },
  { name: '指静脉智能锁 可视猫眼', price: 2399, sold: 734, quality: 97, category: '门锁', tag: '新品' },
  { name: '半导体指纹锁 千元档', price: 999, sold: 2051, quality: 92, category: '门锁', tag: '实惠' },
  { name: '智能摄像头 2K 云台', price: 179, sold: 6218, quality: 93, category: '摄像头', tag: '爆款' },
  { name: '户外太阳能摄像头', price: 249, sold: 3175, quality: 91, category: '摄像头', tag: '热卖' },
  { name: '智能插座 16A 计量款', price: 59, sold: 8432, quality: 90, category: '插座', tag: '实惠' },
  { name: '魔方智能遥控器 空调伴侣', price: 79, sold: 4960, quality: 91, category: '插座', tag: '热卖' },
  { name: '智能插排 6 孔 App 控制', price: 99, sold: 3812, quality: 93, category: '插座', tag: '推荐' }
]

从数据分布来看,商品价格从 59 元的智能插座到 2799 元的扫拖一体机器人,跨度接近 50 倍,覆盖了从入门级到旗舰级的完整价格带。销量数据最高达到 8432(智能插座),最低为 645(擦窗机器人),反映了不同品类的市场热度差异。口碑评分集中在 90 到 98 之间,体现了精选商品的品质门槛。tag 字段使用爆款、实惠、推荐、热卖、新品、旗舰、网红等营销标签,在卡片渲染时以不同颜色的 Tag 徽标呈现,帮助用户快速识别商品定位。

类似地,speakers、robots、lights、trades、messages 数组分别携带了各自品类的专业参数数据。speakers 记录了功率从 5W 到 200W 的 12 款音箱,robots 记录了吸力从 150Pa 到 12000Pa 的 10 款清洁设备,lights 记录了流明从 60lm 到 6400lm 的 10 款灯具。这些数据不仅用于列表展示,更是各 Tab 中柱状图可视化的数据来源。

四、数据计算辅助方法

在这里插入图片描述

应用定义了一组 max 系列私有方法,用于在柱状图可视化中计算各维度的最大值,作为柱体高度的归一化基准。这些方法采用 forEach 遍历数组并逐项比较的方式实现最大值求解,逻辑简洁直观。

private maxWatt(): number {
  let m: number = 0
  this.speakers.forEach((s: SpeakerItem) => {
    if (s.watt > m) {
      m = s.watt
    }
  })
  return m
}

private maxSuction(): number {
  let m: number = 0
  this.robots.forEach((r: RobotItem) => {
    if (r.suction > m) {
      m = r.suction
    }
  })
  return m
}

private maxLumen(): number {
  let m: number = 0
  this.lights.forEach((l: LightItem) => {
    if (l.lumen > m) {
      m = l.lumen
    }
  })
  return m
}

private maxSubsidy(): number {
  let m: number = 0
  this.trades.forEach((t: TradeItem) => {
    if (t.subsidy > m) {
      m = t.subsidy
    }
  })
  return m
}

maxWatt 返回 speakers 数组中的最大功率值(200W),maxSuction 返回 robots 数组中的最大吸力值(12000Pa),maxLumen 返回 lights 数组中的最大流明值(6400lm),maxSubsidy 返回 trades 数组中的最大补贴金额(800元)。这些最大值在对应的柱状图 Builder 中被用作除数,将每个数据项的值除以最大值再乘以基准高度,得到归一化后的柱体高度。例如音箱功率榜中某款 30W 的音箱,其柱体高度计算为 30 / 200 * 90 = 13.5,而 200W 的音箱则为 90,这样不同量级的数据在同一图表中就能保持视觉比例的一致性。

此外,应用还定义了 selName、selPrice、selCategory、selTag、selQuality、selSold 六个选中项访问器方法。这些方法统一处理了 selectedItem 为 null 时的边界情况,返回空字符串或 0 作为默认值,避免了在详情弹窗模板中反复编写空值判断逻辑。这种将防御性编程收敛到方法内部的做法,使得 @Builder 中的模板代码更加专注于布局和样式,可读性显著提升。

五、页面骨架与路由渲染

在这里插入图片描述

build 方法是整个组件的渲染入口,采用 Stack 作为根容器实现主页面与弹窗层的叠加。Stack 内部首先放置一个占满全屏的 Column,该 Column 纵向排列 headerBar、可滚动内容区和 bottomBar;随后通过条件判断依次叠加五种弹窗模态。

build() {
  Stack() {
    Column() {
      this.headerBar()
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            this.tabMarket()
          } else if (this.currentTab === 1) {
            this.tabSpeaker()
          } else if (this.currentTab === 2) {
            this.tabRobot()
          } else if (this.currentTab === 3) {
            this.tabLight()
          } else if (this.currentTab === 4) {
            this.tabTrade()
          } else if (this.currentTab === 5) {
            this.tabMessage()
          } else {
            this.tabMine()
          }
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 10, bottom: 10 })
      }
      .layoutWeight(1)
      .width('100%')
      .scrollBar(BarState.Off)
      this.bottomBar()
    }
    .width('100%')
    .height('100%')

    if (this.showPublish) {
      this.modalPublish()
    }
    if (this.showTrade) {
      this.modalTrade()
    }
    if (this.showWarranty) {
      this.modalWarranty()
    }
    if (this.showDelete) {
      this.modalDelete()
    }
    if (this.showDetail) {
      this.modalDetail()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F5F7FA')
}

这段代码的核心设计在于 Stack 的层级叠加机制。Stack 的子组件按照声明顺序从底层到顶层排列,因此 Column 主页面位于最底层,而五个弹窗模态根据各自 show 状态的条件判断决定是否渲染到上层。当多个弹窗同时为 true 时(例如详情弹窗中点击移除后触发删除确认弹窗),后声明的 modalDelete 会叠加在 modalDetail 之上,形成视觉上的弹窗嵌套效果。

内容区的路由逻辑通过 if-else if 链根据 currentTab 的值调用对应的 @Builder 方法。这种条件渲染方式虽然简单,但在 Tab 数量固定且不多的场景下性能表现优异,因为每次切换 Tab 只需重新执行一个 Builder 的渲染逻辑,不需要虚拟列表的 diff 开销。Scroll 组件包裹内容区并设置 layoutWeight(1) 使其占据 headerBar 和 bottomBar 之间的全部可用空间,scrollBar 设为 BarState.Off 隐藏滚动条以保持界面简洁。

六、顶部导航栏与品类横滑

在这里插入图片描述

headerBar 是应用的顶部导航区域,包含品牌标题、搜索框入口、消息快捷入口和品类横滑标签四个部分。导航栏采用深蓝色背景(#1565C0),与整体科技感主题保持一致。

@Builder
headerBar() {
  Column() {
    Row() {
      Column() {
        Text('智能家电数码港')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
      }
      Column() {
        Row() {
          Text('搜索智能设备 / 型号 / 品牌')
            .fontSize(12)
            .fontColor('#90A4AE')
        }
        .width('100%')
        .height(30)
        .backgroundColor('#FFFFFF')
        .borderRadius(15)
        .justifyContent(FlexAlign.Start)
        .padding({ left: 14 })
      }
      .layoutWeight(1)
      .margin({ left: 12, right: 12 })
      Column() {
        Text('消息')
          .fontSize(13)
          .fontColor('#FFFFFF')
      }
      .onClick(() => {
        this.currentTab = 5
      })
    }
    .width('100%')
    .height(50)
    .padding({ left: 14, right: 14 })
    .alignItems(VerticalAlign.Center)

    Scroll() {
      Row() {
        ForEach(this.categories, (c: string) => {
          Text(c)
            .fontSize(12)
            .fontColor('#E1F5FE')
            .backgroundColor('rgba(255,255,255,0.12)')
            .borderRadius(12)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .margin({ right: 8 })
            .onClick(() => {
              this.selectedCategory = 0
            })
        })
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .padding({ left: 14, bottom: 8 })
  }
  .width('100%')
  .backgroundColor('#1565C0')
}

顶部 Row 采用三列横向布局:左侧品牌标题固定宽度,中间搜索框通过 layoutWeight(1) 占据剩余空间并添加左右 margin 保持间距,右侧消息入口固定宽度。搜索框内部使用浅灰色占位文本模拟输入提示,实际并未接入 TextInput 组件,这是原型设计中常见的占位处理方式。消息入口的 onClick 直接将 currentTab 设为 5 跳转到消息 Tab,实现了快捷导航。

品类横滑区使用 Scroll 组件包裹横向排列的 Row,scrollable 设置为 ScrollDirection.Horizontal 启用横向滚动。每个品类标签使用半透明白色背景和浅蓝色文字,borderRadius(12) 赋予圆角胶囊外观。这一行品类标签不仅起到导航作用,也为用户提供了一目了然的品类概览,用户可以快速了解应用涵盖的六大智能设备品类。

七、底部 Tab 导航栏

bottomBar 是应用的底部导航栏,承载七个 Tab 的切换入口。每个 Tab 项由一个指示条和文字标签组成,选中态和非选中态在指示条宽度、颜色和文字颜色上均有明显区分。

@Builder
bottomBar() {
  Row() {
    ForEach(this.tabs, (tab: string, index: number) => {
      Column() {
        Column()
          .width(index === this.currentTab ? 18 : 6)
          .height(3)
          .borderRadius(2)
          .backgroundColor(index === this.currentTab ? '#00E5FF' : 'rgba(255,255,255,0.3)')
        Text(tab)
          .fontSize(11)
          .fontColor(index === this.currentTab ? '#00E5FF' : 'rgba(255,255,255,0.6)')
          .margin({ top: 5 })
      }
      .layoutWeight(1)
      .height(54)
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.currentTab = index
      })
    })
  }
  .width('100%')
  .backgroundColor('#0D47A1')
}

底部栏使用更深的蓝色背景(#0D47A1)与顶部栏形成层次对比。ForEach 遍历 tabs 数组生成七个等宽的 Tab 项,每个项通过 layoutWeight(1) 平均分配水平空间。指示条的设计颇具巧思:选中时宽度为 18、颜色为青色(#00E5FF),非选中时宽度仅为 6、颜色为半透明白色,这种宽度差异在视觉上形成了一种"放大聚焦"的效果,用户一眼就能识别当前所在 Tab。文字颜色同步切换:选中态为青色高亮,非选中态为半透明白色。每个 Tab 项的 onClick 回调将 currentTab 设为对应索引,触发内容区的条件渲染切换。

八、数码集市 Tab 与统计卡片

tabMarket 是默认展示的首屏 Tab,包含活动横幅、统计卡片和商品列表三个区块。活动横幅以深蓝色卡片承载,左侧展示全屋智能节活动信息,右侧是一个带循环缩放动画的换新入口按钮。

@Builder
tabMarket() {
  Column() {
    Row() {
      Column() {
        Text('全屋智能节')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('套购满 3000 减 300 · 免费上门设计')
          .fontSize(11)
          .fontColor('#B3E5FC')
          .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Column() {
        Text('换新')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1565C0')
        Text('最高补 800')
          .fontSize(10)
          .fontColor('#FFFFFF')
          .margin({ top: 2 })
      }
      .width(72)
      .height(72)
      .backgroundColor('#00E5FF')
      .borderRadius(14)
      .justifyContent(FlexAlign.Center)
      .scale({ x: 1.05, y: 1.05 })
      .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
      .onClick(() => {
        this.showTrade = true
      })
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#0D47A1')
    .borderRadius(14)
    .alignItems(VerticalAlign.Center)

    Row() {
      Column() {
        Text('24')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1565C0')
        Text('在售设备')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .height(64)
      .backgroundColor('#FFFFFF')
      .borderRadius(10)
      .margin({ top: 10 })

      Column() {
        Text('8432')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1565C0')
        Text('最高销量')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .height(64)
      .backgroundColor('#FFFFFF')
      .borderRadius(10)
      .margin({ top: 10, left: 8 })

      Column() {
        Text('延保')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('低至 29 元')
          .fontSize(11)
          .fontColor('#B3E5FC')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .height(64)
      .backgroundColor('#0097A7')
      .borderRadius(10)
      .margin({ top: 10, left: 8 })
      .onClick(() => {
        this.showWarranty = true
      })
    }
    .width('100%')

活动横幅右侧的换新按钮使用了 scale({ x: 1.05, y: 1.05 }) 配合 animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut }),实现了 1.05 倍的循环缩放呼吸效果。这种动画在视觉上模拟了"呼吸"的节奏感,有效吸引用户注意力,引导用户点击进入以旧换新流程。点击后将 showTrade 设为 true,触发以旧换新估价弹窗的渲染。

统计卡片行使用 Row 横向排列四个等宽卡片,分别展示在售设备数、最高销量、设备好评率和延保入口。前三张卡片为白底蓝字的数据展示卡,第四张为青色背景的延保入口卡,点击后弹出延保购买弹窗。卡片之间通过 margin({ left: 8 }) 保持间距,layoutWeight(1) 确保四等分。

九、商品卡片列表与口碑进度条

商品列表使用 ForEach 遍历 digitals 数组,为每条记录渲染一张商品卡片。卡片采用左侧色块占位图加右侧信息区的经典电商布局,信息区包含名称、品类标签、营销标签、口碑进度条、价格和详情入口。

    Column() {
      ForEach(this.digitals, (d: DigitalItem) => {
        Row() {
          Column()
            .width(86)
            .height(86)
            .backgroundColor('#E3F2FD')
            .borderRadius(10)
          Column() {
            Text(d.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#263238')
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            Row() {
              Text(d.category)
                .fontSize(10)
                .fontColor('#1565C0')
                .backgroundColor('#E3F2FD')
                .borderRadius(4)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              Text(d.tag)
                .fontSize(10)
                .fontColor('#FFFFFF')
                .backgroundColor('#0097A7')
                .borderRadius(4)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .margin({ left: 6 })
                .scale({ x: 1.06, y: 1.06 })
                .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
            }
            .margin({ top: 6 })

            Row() {
              Text('口碑')
                .fontSize(10)
                .fontColor('#90A4AE')
              Stack({ alignContent: Alignment.Start }) {
                Column()
                  .width('100%')
                  .height(6)
                  .backgroundColor('#ECEFF1')
                  .borderRadius(3)
                Column()
                  .width(d.quality + '%')
                  .height(6)
                  .backgroundColor('#1565C0')
                  .borderRadius(3)
              }
              .width(90)
              .margin({ left: 6 })
              Text(d.quality + '%')
                .fontSize(10)
                .fontColor('#1565C0')
                .fontWeight(FontWeight.Bold)
                .margin({ left: 6 })
            }
            .margin({ top: 8 })

            Row() {
              Text('¥' + d.price)
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1565C0')
              Text(d.sold + '条评价')
                .fontSize(11)
                .fontColor('#90A4AE')
                .margin({ left: 8 })
              Column().layoutWeight(1)
              Text('看详情')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .backgroundColor('#1565C0')
                .borderRadius(10)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)
            .margin({ top: 8 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 10 })
        }
        .width('100%')
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .padding(10)
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)
        .onClick(() => {
          this.selectedItem = d
          this.showDetail = true
        })
      })
    }

商品名称使用 maxLines(1) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 实现单行截断省略,防止过长名称撑破卡片布局。营销标签(tag)同样应用了 1.06 倍的循环缩放动画,与横幅换新按钮形成视觉节奏呼应。口碑进度条是卡片中最具技术含量的组件:它使用 Stack 作为容器,alignContent 设为 Alignment.Start 使子组件左对齐,底层放置一个 100% 宽度的灰色背景条(#ECEFF1),上层放置宽度为 d.quality + ‘%’ 的蓝色进度条(#1565C0),两层叠加形成进度填充效果。这种"背景条+前景条"的双层 Stack 方案是 ArkUI 中实现进度条的惯用模式,简单而高效。

整个卡片的 onClick 回调将 selectedItem 赋值为当前点击的 DigitalItem 对象,并设置 showDetail 为 true 触发详情弹窗。这里将整个对象引用直接赋给状态变量,ArkUI 的响应式系统会自动追踪对象属性的变化并刷新详情弹窗中依赖这些属性的子组件。

十、音箱功率柱状图与列表

tabSpeaker Tab 的核心亮点是顶部的音箱功率柱状图。该图表使用 ForEach 遍历 speakers 数组,为每款音箱渲染一个垂直柱体,柱体高度根据功率值归一化计算。

@Builder
tabSpeaker() {
  Column() {
    Column() {
      Row() {
        Text('音箱功率榜 (W)')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('瓦数越高声压越强')
          .fontSize(10)
          .fontColor('#B3E5FC')
          .margin({ left: 10 })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

      Row() {
        ForEach(this.speakers, (s: SpeakerItem) => {
          Column() {
            Column()
              .width(15)
              .height(s.watt / this.maxWatt() * 90)
              .backgroundColor('#00E5FF')
              .borderRadius({ topLeft: 3, topRight: 3 })
          }
          .height(94)
          .justifyContent(FlexAlign.End)
          .margin({ right: 5 })
        })
      }
      .width('100%')
      .alignItems(VerticalAlign.End)
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#263238')
    .borderRadius(12)
    .margin({ top: 4 })

柱状图的实现原理值得深入分析。每个柱体是一个 Column 组件,其内部包含一个固定宽度 15 的子 Column 作为实际柱条。柱条高度通过表达式 s.watt / this.maxWatt() * 90 动态计算:先求当前音箱功率与最大功率的比值(0 到 1 之间),再乘以基准高度 90,得到最终像素高度。外层 Column 设置 height(94) 和 justifyContent(FlexAlign.End),使得柱条在容器底部对齐,空出来的顶部空间自然形成"未填充"区域。整个 Row 设置 alignItems(VerticalAlign.End) 确保所有柱体底部对齐,这是柱状图视觉正确性的关键。

柱条的颜色统一为青色(#00E5FF),顶部圆角通过 borderRadius({ topLeft: 3, topRight: 3 }) 只设置上方两个角的圆角,底部保持直角,符合柱状图的视觉惯例。深色背景(#263238)与青色柱体形成鲜明对比,数据可读性极强。这种纯 ArkUI 组件实现的柱状图无需引入任何图表库,利用 Column 的高度属性和 FlexAlign.End 对齐即可完成,体现了声明式 UI 在数据可视化方面的灵活性。

柱状图下方是音箱商品列表,每款音箱展示名称、品牌标签、声道规格、功率和价格,并提供"加延保"入口。列表项的布局结构与数码集市商品卡片类似,但根据音箱品类的特点调整了参数展示维度,增加了声道和功率的突出展示。

十一、机器人吸力对比与续航进度条

tabRobot Tab 同样以柱状图开头,展示各款扫地机器人的吸力参数对比。柱状图实现方式与音箱功率榜一致,但柱体颜色改为蓝色(#1565C0),背景改为白色卡片,整体视觉风格更偏重工程参数感。

@Builder
tabRobot() {
  Column() {
    Column() {
      Text('吸力参数对比 (Pa)')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#263238')
      Text('虚标吸力一律不上架 · 实验室实测')
        .fontSize(10)
        .fontColor('#90A4AE')
        .margin({ top: 4 })
      Row() {
        ForEach(this.robots, (r: RobotItem) => {
          Column() {
            Column()
              .width(18)
              .height(r.suction / this.maxSuction() * 86)
              .backgroundColor('#1565C0')
              .borderRadius({ topLeft: 3, topRight: 3 })
          }
          .height(90)
          .justifyContent(FlexAlign.End)
          .margin({ right: 5 })
        })
      }
      .width('100%')
      .alignItems(VerticalAlign.End)
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ top: 4 })

吸力柱状图的归一化计算使用 r.suction / this.maxSuction() * 86,其中 maxSuction 返回 12000Pa。以吸力 6000Pa 的扫地机 S7 为例,其柱体高度为 6000 / 12000 * 86 = 43,约为最大柱体高度的一半,视觉上直观反映了该机型吸力在品类中的相对水平。柱体宽度设为 18(比音箱的 15 略宽),因为机器人数组只有 10 项,比音箱的 12 项少,更宽的柱体可以更好地填充水平空间。

机器人列表项的亮点是续航进度条的设计。与数码集市商品卡片的口碑进度条不同,续航进度条的宽度计算方式为 r.battery / 220 * 100 + ‘%’,这里 220 是一个固定基准值(而非动态最大值),代表满续航的参考值。因为部分机器人续航可能超过 200 分钟,使用固定基准可以确保进度条不会超过 100%,同时保持不同产品之间的可比性。进度条颜色使用青色(#00E5FF)与吸力柱状图的蓝色形成区分,帮助用户在视觉上区分"吸力"和"续航"两个维度。

每个机器人卡片底部还提供了"立即换新"按钮,该按钮同样应用了 1.05 倍循环缩放动画,点击后将 selectedCond 重置为 0 并弹出以旧换新估价弹窗。

十二、智能灯具瀑布流布局

tabLight Tab 采用了与前几个 Tab 不同的布局方式——使用 Flex 的 wrap 属性实现双列瀑布流。这种布局方式使得灯具商品以两列卡片的形式排列,更接近实际电商 App 的网格展示风格。

@Builder
tabLight() {
  Column() {
    Column() {
      Row() {
        Text('亮度参数 (lm)')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('支持全屋联动')
          .fontSize(10)
          .fontColor('#B3E5FC')
          .margin({ left: 10 })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      Row() {
        ForEach(this.lights, (l: LightItem) => {
          Column() {
            Column()
              .width(16)
              .height(l.lumen / this.maxLumen() * 80)
              .backgroundColor('#FFD54F')
              .borderRadius({ topLeft: 3, topRight: 3 })
          }
          .height(84)
          .justifyContent(FlexAlign.End)
          .margin({ right: 5 })
        })
      }
      .width('100%')
      .alignItems(VerticalAlign.End)
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#1565C0')
    .borderRadius(12)
    .margin({ top: 4 })

    Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
      ForEach(this.lights, (l: LightItem) => {
        Column() {
          Column()
            .width('100%')
            .height(72)
            .backgroundColor('#FFF8E1')
            .borderRadius(10)
          Text(l.name)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
            .margin({ top: 6 })
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(l.colorTemp + ' · ' + l.lumen + 'lm')
            .fontSize(10)
            .fontColor('#90A4AE')
            .margin({ top: 2 })
          Row() {
            Text('¥' + l.price)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1565C0')
            Text('适配' + l.rooms + '房间')
              .fontSize(10)
              .fontColor('#78909C')
              .margin({ left: 6 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ top: 6 })
          Text('配对购买')
            .fontSize(11)
            .fontColor('#FFFFFF')
            .backgroundColor('#0097A7')
            .borderRadius(12)
            .padding({ left: 12, right: 12, top: 4, bottom: 4 })
            .margin({ top: 6 })
            .onClick(() => {
              this.qty = 1
              this.showWarranty = true
            })
        }
        .width('48%')
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .padding(10)
        .margin({ top: 10 })
        .alignItems(HorizontalAlign.Start)
      })
    }
    .width('100%')
  }
  .width('100%')
}

Flex 容器设置 wrap: FlexWrap.Wrap 和 justifyContent: FlexAlign.SpaceBetween,子项宽度设为 48%(略小于 50% 以留出间距),这样每行容纳两个卡片,多余的空间由 SpaceBetween 分配到两个卡片之间。这种方案比 Grid 组件更灵活,因为卡片高度可以随内容自适应,不受网格行高的约束。

亮度柱状图位于顶部,使用琥珀色(#FFD54F)柱体与蓝色背景形成暖色调对比,呼应灯具的"光"主题。每款灯具卡片内部包含色块占位图、名称、色温与流明规格、价格与适配房间数、以及"配对购买"入口按钮。色温信息以 “2700-6500K” 这样的区间格式展示,流明以 “lm” 单位标注,专业参数的呈现帮助用户做出精确的选购决策。

十三、以旧换新补贴可视化

tabTrade Tab 是以旧换新业务的核心展示页,顶部展示各品类换新补贴金额的柱状图,底部以列表形式展示每笔以旧换新记录的详细信息,包括品牌、使用年限、旧机估值和补贴金额。

@Builder
tabTrade() {
  Column() {
    Column() {
      Row() {
        Text('国家补贴 + 厂补叠加')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('最高省 800')
          .fontSize(11)
          .fontColor('#00E5FF')
          .margin({ left: 10 })
          .scale({ x: 1.08, y: 1.08 })
          .animation({ duration: 600, iterations: -1, curve: Curve.EaseInOut })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .alignItems(VerticalAlign.Center)
      Row() {
        ForEach(this.trades, (t: TradeItem) => {
          Column() {
            Column()
              .width(14)
              .height(t.subsidy / this.maxSubsidy() * 72)
              .backgroundColor('#00E5FF')
              .borderRadius({ topLeft: 3, topRight: 3 })
          }
          .height(76)
          .justifyContent(FlexAlign.End)
          .margin({ right: 5 })
        })
      }
      .width('100%')
      .alignItems(VerticalAlign.End)
      .margin({ top: 10 })
      Text('各品类换新补贴金额 (元)')
        .fontSize(10)
        .fontColor('#B3E5FC')
        .margin({ top: 6 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#0D47A1')
    .borderRadius(12)
    .margin({ top: 4 })

补贴柱状图的归一化计算为 t.subsidy / this.maxSubsidy() * 72,其中 maxSubsidy 返回 800 元。以补贴 400 元的旧手机换新为例,柱体高度为 400 / 800 * 72 = 36,恰好为最大高度的一半,视觉上清晰地传达了"中等补贴水平"的信息。"最高省 800"文案应用了 1.08 倍的循环缩放动画,缩放比例比其他动画略大,进一步强调了补贴力度的吸引力。

每条换新记录的列表项中包含一个"机龄折旧"进度条,其宽度计算方式为 (100 - t.year * 8) + ‘%’。这是一个线性折旧模型:每使用一年,折旧进度减少 8 个百分点。例如使用 3 年的设备折旧进度为 100 - 24 = 76%,使用 8 年的设备折旧进度为 100 - 64 = 36%。这种简化的线性折旧模型虽然在真实场景中需要考虑品牌保值率等因素,但在演示层面已经足够直观地传达了"使用越久估值越低"的核心逻辑。

十四、消息中心与未读红点动画

tabMessage Tab 展示站内消息列表,每条消息包含首字母头像、标题、未读红点、内容摘要和时间。未读数大于 0 时渲染脉冲红点,点击消息后未读数清零。

@Builder
tabMessage() {
  Column() {
    Row() {
      Text('消息中心')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#263238')
      Text('6条未读')
        .fontSize(11)
        .fontColor('#1565C0')
        .margin({ left: 8 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ top: 4 })

    Column() {
      ForEach(this.messages, (m: MessageItem) => {
        Row() {
          Column() {
            Text(m.title.substring(0, 1))
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
          }
          .width(44)
          .height(44)
          .backgroundColor('#0D47A1')
          .borderRadius(22)
          .justifyContent(FlexAlign.Center)

          Column() {
            Row() {
              Text(m.title)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#263238')
              if (m.unread > 0) {
                Text(m.unread + '')
                  .fontSize(10)
                  .fontColor('#FFFFFF')
                  .backgroundColor('#1565C0')
                  .borderRadius(8)
                  .padding({ left: 6, right: 6, top: 1, bottom: 1 })
                  .margin({ left: 6 })
                  .scale({ x: 1.1, y: 1.1 })
                  .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
              }
            }
            Text(m.content)
              .fontSize(12)
              .fontColor('#90A4AE')
              .margin({ top: 3 })
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 10 })

          Text(m.time)
            .fontSize(11)
            .fontColor('#B0BEC5')
        }
        .width('100%')
        .padding(12)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)
        .onClick(() => {
          m.unread = 0
        })
      })
    }
    .width('100%')
  }
  .width('100%')
}

消息头像使用 m.title.substring(0, 1) 提取标题首字母作为头像内容,这是一种轻量的头像生成方案,无需预置图片资源即可为每条消息提供视觉标识。头像背景统一为深蓝色(#0D47A1),圆形(borderRadius(22) 对应 44 的宽高)。

未读红点的实现使用了 if 条件渲染:当 m.unread > 0 时才渲染红点组件,红点显示未读数量并应用 1.1 倍循环缩放动画。这种脉冲效果模拟了常见的未读消息提醒交互模式,在视觉上持续吸引注意力。整个消息项的 onClick 回调直接修改 m.unread = 0,由于 messages 数组的元素是对象引用,ArkUI 的响应式系统会检测到对象属性的变化并自动移除红点组件,实现"点击即已读"的交互效果。

十五、个人中心与功能菜单

tabMine Tab 是用户的个人中心页面,包含用户信息卡片、数据统计行和功能菜单列表。用户信息卡片展示了头像、昵称、会员等级和已联动设备数,右侧的"我的场景"入口应用了循环缩放动画。

@Builder
tabMine() {
  Column() {
    Row() {
      Column()
        .width(64)
        .height(64)
        .backgroundColor('#B3E5FC')
        .borderRadius(32)
      Column() {
        Text('全屋智能玩家')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
        Text('PLUS 会员 · 已联动 26 台设备')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 12 })
      Text('我的场景')
        .fontSize(12)
        .fontColor('#FFFFFF')
        .backgroundColor('#0097A7')
        .borderRadius(12)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .scale({ x: 1.05, y: 1.05 })
        .animation({ duration: 800, iterations: -1, curve: Curve.EaseInOut })
        .onClick(() => {
          this.showWarranty = true
        })
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ top: 4 })
    .alignItems(VerticalAlign.Center)

    Row() {
      Column() {
        Text('18')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
        Text('在途设备')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      Column() {
        Text('4')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
        Text('延保中')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      Column() {
        Text('2')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
        Text('换新单')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      Column() {
        Text('37')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
        Text('收藏夹')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
    }
    .width('100%')
    .padding({ top: 14, bottom: 14 })
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ top: 10 })

    Column() {
      ForEach(['我的订单', '换新进度', '延保管理', '智能场景', '客服中心', '清除浏览记录'], (m: string, idx: number) => {
        Row() {
          Text(m)
            .fontSize(14)
            .fontColor('#263238')
          Column().layoutWeight(1)
          Text('>')
            .fontSize(14)
            .fontColor('#B0BEC5')
        }
        .width('100%')
        .padding({ top: 14, bottom: 14 })
        .border({ width: 1, color: '#F5F7FA' })
        .onClick(() => {
          if (idx === 5) {
            this.showDelete = true
          }
        })
      })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .padding({ left: 14, right: 14 })
    .margin({ top: 10 })
  }
  .width('100%')
}

数据统计行使用 Row 横向排列四个等宽统计项:在途设备 18、延保中 4、换新单 2、收藏夹 37。每个统计项上方是大号加粗数字,下方是小号灰色标签,形成了清晰的数据层级。功能菜单列表通过 ForEach 遍历字符串数组生成六行菜单项,每行包含菜单名称、弹性占位和右箭头。菜单项之间使用 border({ width: 1, color: ‘#F5F7FA’ }) 添加浅色分割线。"清除浏览记录"项的 onClick 触发 showDelete 为 true,弹出确认删除弹窗。

十六、以旧换新估价弹窗

modalTrade 是以旧换新估价的核心交互弹窗,采用底部弹出样式。弹窗顶部实时显示根据品牌和成色计算的补贴金额,中部提供品牌选择和成色选择,底部提供"再想想"和"预约上门"两个操作按钮。

@Builder
modalTrade() {
  Column() {
    Column().layoutWeight(1).width('100%')
    Column() {
      Row() {
        Text('以旧换新估价')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
        Text('补 ¥' + ((this.selectedBrand + 1) * 90 + (this.selectedCond + 1) * 50))
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0097A7')
          .scale({ x: 1.05, y: 1.05 })
          .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .alignItems(VerticalAlign.Center)

      Column() {
        Text('品牌')
          .fontSize(13)
          .fontColor('#78909C')
        Row() {
          ForEach(this.brands, (b: string, idx: number) => {
            Text(b)
              .fontSize(12)
              .fontColor(this.selectedBrand === idx ? '#FFFFFF' : '#78909C')
              .backgroundColor(this.selectedBrand === idx ? '#1565C0' : '#F5F7FA')
              .borderRadius(8)
              .padding({ left: 14, right: 14, top: 8, bottom: 8 })
              .margin({ right: 8, top: 10 })
              .onClick(() => {
                this.selectedBrand = idx
              })
          })
        }
        .width('100%')
        .flexWrap(FlexWrap.Wrap)
      }
      .width('100%')
      .margin({ top: 16 })
      .alignItems(HorizontalAlign.Start)

      Column() {
        Text('成色')
          .fontSize(13)
          .fontColor('#78909C')
        Row() {
          ForEach(this.conds, (c: string, idx: number) => {
            Text(c)
              .fontSize(12)
              .fontColor(this.selectedCond === idx ? '#FFFFFF' : '#78909C')
              .backgroundColor(this.selectedCond === idx ? '#0097A7' : '#F5F7FA')
              .borderRadius(8)
              .padding({ left: 14, right: 14, top: 8, bottom: 8 })
              .margin({ right: 8, top: 10 })
              .onClick(() => {
                this.selectedCond = idx
              })
          })
        }
        .width('100%')
        .flexWrap(FlexWrap.Wrap)
      }
      .width('100%')
      .margin({ top: 16 })
      .alignItems(HorizontalAlign.Start)

      Text('上门取件 · 旧机款直接抵扣新机款')
        .fontSize(12)
        .fontColor('#1565C0')
        .margin({ top: 14 })

      Row() {
        Text('再想想')
          .fontSize(14)
          .fontColor('#78909C')
          .layoutWeight(1)
          .height(42)
          .textAlign(TextAlign.Center)
          .backgroundColor('#F5F7FA')
          .borderRadius(21)
          .onClick(() => {
            this.showTrade = false
          })
        Text('预约上门')
          .fontSize(14)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
          .height(42)
          .textAlign(TextAlign.Center)
          .backgroundColor('#1565C0')
          .borderRadius(21)
          .margin({ left: 10 })
          .onClick(() => {
            this.showTrade = false
          })
      }
      .width('100%')
      .margin({ top: 18 })
    }
    .width('100%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 16, topRight: 16 })
    .padding(16)
    .onClick((event: ClickEvent) => {
      event.stopPropagation()
    })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(0,0,0,0.5)')
  .onClick(() => {
    this.showTrade = false
  })
}

补贴金额的计算公式为 (this.selectedBrand + 1) * 90 + (this.selectedCond + 1) * 50。品牌索引加 1 后乘以 90 元,成色索引加 1 后乘以 50 元,两者相加得到总补贴。例如选择"小米"(索引 0)和"九成新以上"(索引 0),补贴为 90 + 50 = 140 元;选择"云鲸"(索引 5)和"有明显使用痕迹"(索引 3),补贴为 540 + 200 = 740 元。这个公式体现了"品牌越好、成色越新,补贴越高"的定价逻辑。补贴金额文本应用了 1.05 倍循环缩放动画,持续吸引视觉焦点。

弹窗的关闭机制采用了双重事件处理:外层 Column 设置半透明黑色背景和 onClick 回调(点击遮罩区域关闭),内层白色卡片设置 onClick((event: ClickEvent) => { event.stopPropagation() })(阻止事件冒泡,点击卡片内部不关闭)。这种"遮罩点击关闭 + 内容点击阻止冒泡"的模式是模态弹窗的标准实现方式,在 ArkUI 中通过事件冒泡机制优雅地实现。

十七、延保服务购买弹窗

modalWarranty 是延保服务购买弹窗,采用居中弹出样式。弹窗提供保障方案选择、保障台数步进器和合计金额计算,底部提供确认投保按钮。

@Builder
modalWarranty() {
  Column() {
    Column() {
      Text('购买延保服务')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#263238')
        .margin({ top: 18 })

      Column() {
        Text('保障方案')
          .fontSize(13)
          .fontColor('#78909C')
        Row() {
          ForEach(this.warrantyOptions, (w: string, idx: number) => {
            Text(w)
              .fontSize(12)
              .fontColor(this.selectedYears === idx ? '#FFFFFF' : '#78909C')
              .backgroundColor(this.selectedYears === idx ? '#1565C0' : '#F5F7FA')
              .borderRadius(14)
              .padding({ left: 14, right: 14, top: 8, bottom: 8 })
              .margin({ right: 8, top: 10 })
              .onClick(() => {
                this.selectedYears = idx
              })
          })
        }
        .width('100%')
        .flexWrap(FlexWrap.Wrap)
      }
      .width('100%')
      .margin({ top: 16 })
      .alignItems(HorizontalAlign.Start)

      Row() {
        Text('保障台数')
          .fontSize(13)
          .fontColor('#78909C')
        Column().layoutWeight(1)
        Row() {
          Text('-')
            .fontSize(15)
            .fontColor('#78909C')
            .width(28)
            .height(28)
            .textAlign(TextAlign.Center)
            .backgroundColor('#F5F7FA')
            .borderRadius(6)
            .onClick(() => {
              if (this.qty > 1) {
                this.qty -= 1
              }
            })
          Text(this.qty + '')
            .fontSize(14)
            .fontColor('#263238')
            .width(36)
            .height(28)
            .textAlign(TextAlign.Center)
          Text('+')
            .fontSize(15)
            .fontColor('#78909C')
            .width(28)
            .height(28)
            .textAlign(TextAlign.Center)
            .backgroundColor('#F5F7FA')
            .borderRadius(6)
            .onClick(() => {
              if (this.qty < 12) {
                this.qty += 1
              }
            })
        }
      }
      .width('100%')
      .margin({ top: 16 })
      .alignItems(VerticalAlign.Center)

      Text('合计:¥' + ((this.selectedYears + 1) * 29 * this.qty))
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1565C0')
        .margin({ top: 18 })
        .scale({ x: 1.04, y: 1.04 })
        .animation({ duration: 650, iterations: -1, curve: Curve.EaseInOut })

      Text('确认投保')
        .fontSize(14)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .height(42)
        .textAlign(TextAlign.Center)
        .backgroundColor('#1565C0')
        .borderRadius(21)
        .margin({ top: 20, bottom: 18 })
        .onClick(() => {
          this.showWarranty = false
        })
    }
    .width('86%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .padding({ left: 18, right: 18 })
    .onClick((event: ClickEvent) => {
      event.stopPropagation()
    })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(0,0,0,0.5)')
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.showWarranty = false
  })
}

保障台数步进器是弹窗中的核心交互组件。减号按钮的 onClick 包含边界检查 if (this.qty > 1),确保台数不会低于 1;加号按钮包含 if (this.qty < 12),确保台数不超过 12。中间的数字显示使用 Text(this.qty + ‘’) 将数字转为字符串渲染。每当 qty 变化时,合计金额文本会自动更新为 (this.selectedYears + 1) * 29 * this.qty。例如选择"2 年延保"(索引 1)和 3 台设备,合计为 2 * 29 * 3 = 174 元。

弹窗外层 Column 设置 justifyContent(FlexAlign.Center) 使白色卡片垂直居中,与底部弹出的 modalTrade 形成不同的弹出位置风格。卡片宽度设为 86%,两侧留出间距,圆角为 16,整体呈居中对话框样式。

十八、商品详情侧滑面板

modalDetail 是商品详情弹窗,采用从右侧滑入的面板样式,占据屏幕 78% 宽度。面板内部使用 Scroll 实现内容滚动,展示商品大图占位区、价格、名称、品类标签、口碑评分进度条、销量信息、操作按钮和移除入口。

@Builder
modalDetail() {
  Row() {
    Scroll() {
      Column() {
        Column()
          .width('100%')
          .height(180)
          .backgroundColor('#B3E5FC')
          .borderRadius({ topLeft: 16, bottomLeft: 16 })

        Column() {
          Row() {
            Text('¥' + this.selPrice())
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1565C0')
            Text('补贴后再省 300')
              .fontSize(11)
              .fontColor('#FFFFFF')
              .backgroundColor('#0097A7')
              .borderRadius(10)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .margin({ left: 10 })
              .scale({ x: 1.06, y: 1.06 })
              .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
          }
          .alignItems(VerticalAlign.Center)

          Text(this.selName())
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
            .margin({ top: 8 })

          Row() {
            Text(this.selCategory())
              .fontSize(10)
              .fontColor('#1565C0')
              .backgroundColor('#E3F2FD')
              .borderRadius(4)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            Text(this.selTag())
              .fontSize(10)
              .fontColor('#FFFFFF')
              .backgroundColor('#0D47A1')
              .borderRadius(4)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .margin({ left: 6 })
          }
          .margin({ top: 8 })

          Column() {
            Text('口碑评分 ' + this.selQuality() + '%')
              .fontSize(12)
              .fontColor('#78909C')
            Stack({ alignContent: Alignment.Start }) {
              Column()
                .width('100%')
                .height(8)
                .backgroundColor('#ECEFF1')
                .borderRadius(4)
              Column()
                .width(this.selQuality() + '%')
                .height(8)
                .backgroundColor('#1565C0')
                .borderRadius(4)
            }
            .width('100%')
            .margin({ top: 6 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .margin({ top: 14 })

          Row() {
            Text(this.selSold() + ' 条评价')
              .fontSize(12)
              .fontColor('#90A4AE')
            Text('全国联保')
              .fontSize(12)
              .fontColor('#90A4AE')
              .margin({ left: 12 })
            Text('免费安装')
              .fontSize(12)
              .fontColor('#0097A7')
              .margin({ left: 12 })
          }
          .margin({ top: 12 })

          Row() {
            Text('加延保')
              .fontSize(14)
              .fontColor('#0097A7')
              .fontWeight(FontWeight.Bold)
              .layoutWeight(1)
              .height(40)
              .textAlign(TextAlign.Center)
              .backgroundColor('#E0F7FA')
              .borderRadius(20)
              .onClick(() => {
                this.selectedYears = 0
                this.showWarranty = true
              })
            Text('以旧换新')
              .fontSize(14)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .layoutWeight(1)
              .height(40)
              .textAlign(TextAlign.Center)
              .backgroundColor('#1565C0')
              .borderRadius(20)
              .margin({ left: 10 })
              .onClick(() => {
                this.selectedCond = 0
                this.showTrade = true
              })
          }
          .width('100%')
          .margin({ top: 18 })

          Row() {
            Text('移除该设备')
              .fontSize(13)
              .fontColor('#1565C0')
              .padding({ top: 12 })
          }
          .width('100%')
          .onClick(() => {
            this.showDelete = true
          })
        }
        .width('100%')
        .padding(16)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
    }
    .width('78%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .onClick((event: ClickEvent) => {
      event.stopPropagation()
    })

    Column()
      .layoutWeight(1)
      .height('100%')
      .onClick(() => {
        this.showDetail = false
      })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(0,0,0,0.5)')
  .onClick(() => {
    this.showDetail = false
  })
}

详情面板的布局采用 Row 水平排列两个子项:左侧白色面板占 78% 宽度,右侧透明遮罩占剩余 22%。左侧面板使用 Scroll 包裹内容,使超出屏幕高度的内容可以纵向滚动。顶部图片占位区高度 180,使用 borderRadius({ topLeft: 16, bottomLeft: 16 }) 只设置左侧两个角的圆角,右侧为直角,营造出从右侧滑入的视觉效果。

面板中的所有商品数据都通过 sel 系列方法获取,这些方法内部处理了 selectedItem 为 null 的边界情况。口碑评分进度条使用与商品卡片相同的双层 Stack 方案,宽度直接使用 this.selQuality() + ‘%’ 动态计算。底部操作区提供"加延保"和"以旧换新"两个入口,分别触发对应的弹窗模态,形成了弹窗间的导航链路:详情弹窗可以跳转到延保弹窗或以旧换新弹窗。"移除该设备"入口位于最底部,点击后触发确认删除弹窗。

流程图

以下流程图展示了应用从启动到完成以旧换新交易的完整用户交互链路,涵盖了 Tab 切换、商品浏览、详情查看、弹窗导航和交易确认等核心流程节点。

点击底部 Tab

点击商品卡片

点击加延保

点击以旧换新

点击移除设备

确认移除

取消

点击换新入口

点击消息

应用启动

渲染数码集市 Tab

用户操作

切换 currentTab

渲染对应 Tab 视图

selectedItem 赋值

showDetail = true

弹出商品详情面板

详情面板操作

selectedYears = 0

showWarranty = true

弹出延保购买弹窗

选择方案与台数

确认投保

showWarranty = false

selectedCond = 0

showTrade = true

弹出估价弹窗

选择品牌与成色

实时计算补贴金额

预约上门

showTrade = false

showDelete = true

弹出确认删除弹窗

用户确认

关闭删除与详情弹窗

技术点对比表格

技术维度数码集市商品卡片音箱功率柱状图灯具瀑布流以旧换新估价弹窗商品详情面板
布局容器Row + ColumnRow + ColumnFlex WrapColumn + RowRow + Scroll
数据驱动DigitalItem 数组SpeakerItem.wattLightItem 数组selectedBrand/CondselectedItem 对象
可视化方式口碑进度条双层 Stack归一化柱体高度双列卡片网格补贴金额表达式评分进度条
动画效果tag 标签 1.06 倍缩放无动画无动画补贴金额 1.05 倍缩放补贴标签 1.06 倍缩放
交互模式点击打开详情纯展示配对购买入口品牌成色选择+步进器侧滑面板+滚动内容
状态依赖selectedItemmaxWatt 方法qty, showWarrantyselectedBrand, selectedCond, showTradeselectedItem, showDetail
弹窗触发showDetailshowWarrantyshowTrade 自身showWarranty, showTrade, showDelete
归一化算法quality/100watt/maxWatt*90(brand+1)*90+(cond+1)*50quality/100
圆角处理borderRadius(12)topLeft/topRight 3borderRadius(12)topLeft/topRight 16topLeft/bottomLeft 16

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface DigitalItem {
  name: string
  price: number
  sold: number
  quality: number
  category: string
  tag: string
}

interface SpeakerItem {
  name: string
  watt: number
  channels: string
  brand: string
  price: number
}

interface RobotItem {
  name: string
  suction: number
  battery: number
  price: number
  mapNav: string
}

interface LightItem {
  name: string
  lumen: number
  colorTemp: string
  price: number
  rooms: number
}

interface TradeItem {
  name: string
  brand: string
  oldVal: number
  subsidy: number
  year: number
}

interface MessageItem {
  title: string
  content: string
  time: string
  unread: number
}

@Entry
@Component
struct SmartHomePortPage {
  @State currentTab: number = 0
  @State showPublish: boolean = false
  @State showTrade: boolean = false
  @State showWarranty: boolean = false
  @State showDelete: boolean = false
  @State showDetail: boolean = false
  @State selectedItem: DigitalItem | null = null
  @State selectedCategory: number = 0
  @State selectedBrand: number = 0
  @State selectedCond: number = 0
  @State selectedYears: number = 0
  @State qty: number = 1
  @State inputName: string = ''

  private tabs: string[] = ['数码集市', '智能音箱', '扫地机器人', '智能灯具', '以旧换新', '消息', '我的']
  private categories: string[] = ['音箱', '机器人', '灯具', '门锁', '摄像头', '插座']
  private brands: string[] = ['小米', '华为', '京东京造', '海尔', '美的', '云鲸']
  private conds: string[] = ['九成新以上', '八成新', '七成新', '有明显使用痕迹']
  private warrantyOptions: string[] = ['1 年延保', '2 年延保', '3 年延保', '碎屏保']

  private digitals: DigitalItem[] = [
    { name: '智能屏音箱 Pro 8 英寸', price: 599, sold: 2143, quality: 95, category: '音箱', tag: '爆款' },
    { name: '智能音箱 mini 电池版', price: 149, sold: 6824, quality: 92, category: '音箱', tag: '实惠' },
    { name: '回音壁音箱 3.1 声道', price: 1299, sold: 876, quality: 96, category: '音箱', tag: '推荐' },
    { name: '无线麦克风音箱 K 歌', price: 399, sold: 1320, quality: 90, category: '音箱', tag: '热卖' },
    { name: '扫地机器人 S7 激光导航', price: 1899, sold: 1567, quality: 97, category: '机器人', tag: '旗舰' },
    { name: '扫拖一体机器人 X10', price: 2799, sold: 982, quality: 96, category: '机器人', tag: '新品' },
    { name: '自动集尘扫地机 T8', price: 1599, sold: 1745, quality: 93, category: '机器人', tag: '爆款' },
    { name: '擦窗机器人 方形双吸', price: 1199, sold: 645, quality: 92, category: '机器人', tag: '推荐' },
    { name: '智能吸尘器轻量手持', price: 899, sold: 1234, quality: 91, category: '机器人', tag: '热卖' },
    { name: '全屋智能吸擦一体', price: 3499, sold: 321, quality: 98, category: '机器人', tag: '旗舰' },
    { name: '智能吸顶灯 客厅 90cm', price: 499, sold: 2109, quality: 94, category: '灯具', tag: '爆款' },
    { name: '智能台灯 护眼国AA', price: 259, sold: 4532, quality: 95, category: '灯具', tag: '热卖' },
    { name: '智能灯带 5m RGB 联动', price: 89, sold: 5743, quality: 90, category: '灯具', tag: '实惠' },
    { name: '智能筒灯 6 只装蓝牙', price: 199, sold: 2867, quality: 91, category: '灯具', tag: '推荐' },
    { name: '日落灯氛围投影', price: 129, sold: 3912, quality: 88, category: '灯具', tag: '网红' },
    { name: '人脸识别智能门锁 3D', price: 1899, sold: 1105, quality: 96, category: '门锁', tag: '爆款' },
    { name: '指静脉智能锁 可视猫眼', price: 2399, sold: 734, quality: 97, category: '门锁', tag: '新品' },
    { name: '半导体指纹锁 千元档', price: 999, sold: 2051, quality: 92, category: '门锁', tag: '实惠' },
    { name: '智能摄像头 2K 云台', price: 179, sold: 6218, quality: 93, category: '摄像头', tag: '爆款' },
    { name: '户外太阳能摄像头', price: 249, sold: 3175, quality: 91, category: '摄像头', tag: '热卖' },
    { name: '双摄视频门铃 室内外', price: 399, sold: 1843, quality: 92, category: '摄像头', tag: '推荐' },
    { name: '智能插座 16A 计量款', price: 59, sold: 8432, quality: 90, category: '插座', tag: '实惠' },
    { name: '魔方智能遥控器 空调伴侣', price: 79, sold: 4960, quality: 91, category: '插座', tag: '热卖' },
    { name: '智能插排 6 孔 App 控制', price: 99, sold: 3812, quality: 93, category: '插座', tag: '推荐' }
  ]

  private speakers: SpeakerItem[] = [
    { name: '智能屏音箱 Pro 8', watt: 30, channels: '2.1 声道', brand: '小米', price: 599 },
    { name: 'Soundbox 电视伴侣', watt: 45, channels: '3.1 声道', brand: '华为', price: 999 },
    { name: '回音壁家庭影院 5.0', watt: 120, channels: '5.1 声道', brand: '京造', price: 1499 },
    { name: '智能音箱 mini', watt: 5, channels: '全频单声道', brand: '小米', price: 149 },
    { name: '便携蓝牙音箱 防水', watt: 20, channels: '双声道', brand: '京造', price: 199 },
    { name: '圆形 AI 音箱 声纹', watt: 12, channels: '360 环绕', brand: '华为', price: 349 },
    { name: 'K 歌音箱 双无线麦', watt: 60, channels: '2.0 声道', brand: '京造', price: 399 },
    { name: '桌面监听音箱 对装', watt: 40, channels: '2.0 声道', brand: '漫步者', price: 559 },
    { name: '低音炮 有源 10 寸', watt: 150, channels: '单声道超低', brand: 'JBL', price: 1899 },
    { name: '书架音箱 HiFi 无源', watt: 80, channels: '2.0 声道', brand: '惠威', price: 1299 },
    { name: '户外拉杆音箱 12 寸', watt: 200, channels: '2.1 声道', brand: '山水', price: 1099 },
    { name: '智能闹钟音箱 床头', watt: 6, channels: '全频单声道', brand: '小米', price: 129 }
  ]

  private robots: RobotItem[] = [
    { name: '扫地机 S7 激光版', suction: 6000, battery: 180, price: 1899, mapNav: 'LDS 激光' },
    { name: '扫拖 X10 自动集尘', suction: 8000, battery: 210, price: 2799, mapNav: '结构光避障' },
    { name: '云鲸拖地机 J4', suction: 7000, battery: 200, price: 3699, mapNav: 'dToF 导航' },
    { name: '石头 T8 集尘套装', suction: 5000, battery: 175, price: 1599, mapNav: 'LDS 激光' },
    { name: '科沃斯 T30 Pro', suction: 9000, battery: 220, price: 3999, mapNav: 'TrueMapping' },
    { name: '追觅 S20 热风烘干', suction: 7300, battery: 195, price: 2999, mapNav: 'AI 视觉' },
    { name: '擦窗机 W1 双吸盘', suction: 2800, battery: 90, price: 1199, mapNav: '路径规划' },
    { name: '戴森 V12 手持', suction: 150, battery: 60, price: 3290, mapNav: '压电传感' },
    { name: '无线洗地机 F8', suction: 12000, battery: 35, price: 2299, mapNav: '滚刷活水' },
    { name: '迷你扫地机 床底', suction: 2000, battery: 80, price: 399, mapNav: '随机碰撞' }
  ]

  private lights: LightItem[] = [
    { name: '智能吸顶灯 90cm', lumen: 6400, colorTemp: '2700-6500K', price: 499, rooms: 3 },
    { name: '护眼台灯 国AA 级', lumen: 1200, colorTemp: '3000-5000K', price: 259, rooms: 1 },
    { name: 'RGB 灯带 5 米', lumen: 900, colorTemp: '1600 万色', price: 89, rooms: 4 },
    { name: '智能筒灯 6 只装', lumen: 720, colorTemp: '2700-6000K', price: 199, rooms: 6 },
    { name: '日落氛围投影灯', lumen: 400, colorTemp: '固定 1800K', price: 129, rooms: 2 },
    { name: '智能轨道射灯 三头', lumen: 2100, colorTemp: '3500-5700K', price: 399, rooms: 2 },
    { name: '智能灯泡 E27 两只', lumen: 800, colorTemp: '2700-6500K', price: 79, rooms: 5 },
    { name: '屏幕挂灯 Plus', lumen: 500, colorTemp: '2800-5500K', price: 169, rooms: 1 },
    { name: '落地灯 客厅北欧', lumen: 1800, colorTemp: '3000-4000K', price: 329, rooms: 1 },
    { name: '感应夜灯 卧室款', lumen: 60, colorTemp: '暖光 2700K', price: 49, rooms: 6 }
  ]

  private trades: TradeItem[] = [
    { name: '旧手机换新补贴', brand: '小米', oldVal: 600, subsidy: 400, year: 3 },
    { name: '旧电视 55 寸换新', brand: '海信', oldVal: 500, subsidy: 350, year: 5 },
    { name: '旧冰箱双门换新', brand: '海尔', oldVal: 400, subsidy: 300, year: 8 },
    { name: '旧滚筒洗衣机换新', brand: '美的', oldVal: 450, subsidy: 320, year: 6 },
    { name: '旧空调挂机换新', brand: '格力', oldVal: 700, subsidy: 500, year: 7 },
    { name: '旧扫地机折旧换新', brand: '石头', oldVal: 350, subsidy: 250, year: 3 },
    { name: '旧音箱回音壁换新', brand: 'JBL', oldVal: 300, subsidy: 200, year: 4 },
    { name: '旧平板电脑换新', brand: '华为', oldVal: 800, subsidy: 600, year: 2 },
    { name: '旧笔记本换新补贴', brand: '联想', oldVal: 1200, subsidy: 800, year: 5 },
    { name: '旧智能手表换新', brand: '苹果', oldVal: 900, subsidy: 650, year: 3 },
    { name: '旧电饭煲换新', brand: '苏泊尔', oldVal: 80, subsidy: 50, year: 4 },
    { name: '旧净水器滤芯机换新', brand: '沁园', oldVal: 200, subsidy: 150, year: 6 }
  ]

  private messages: MessageItem[] = [
    { title: '发货通知', content: '您的扫地机器人已由厂家直发,明日送达', time: '12:31', unread: 1 },
    { title: '补贴到账', content: '以旧换新补贴 400 元已到账京东余额', time: '11:05', unread: 1 },
    { title: '安装预约', content: '智能门锁安装师傅已预约周六上午', time: '昨天', unread: 2 },
    { title: '固件升级', content: '您的音箱有新固件 v3.2 支持连续对话', time: '昨天', unread: 0 },
    { title: '降价提醒', content: '您关注的智能台灯降价 60 元', time: '08-22', unread: 0 },
    { title: '延保提醒', content: '您的机器人延保即将到期可续费', time: '08-21', unread: 1 },
    { title: '场景推荐', content: '为您推荐离家模式全屋联动场景', time: '08-20', unread: 0 },
    { title: '物流更新', content: '您的智能灯带已出库', time: '08-19', unread: 0 },
    { title: '评价有礼', content: '晒单评价送智能插座一个', time: '08-18', unread: 1 },
    { title: '会员日', content: 'PLUS 会员日数码全场 12 期免息', time: '08-17', unread: 0 },
    { title: '回收上门', content: '旧家电回收师傅已接单,注意来电', time: '08-16', unread: 0 },
    { title: '新品首发', content: '扫拖机器人新品首发预定立省 500', time: '08-15', unread: 0 }
  ]

  private maxWatt(): number {
    let m: number = 0
    this.speakers.forEach((s: SpeakerItem) => {
      if (s.watt > m) {
        m = s.watt
      }
    })
    return m
  }

  private maxSuction(): number {
    let m: number = 0
    this.robots.forEach((r: RobotItem) => {
      if (r.suction > m) {
        m = r.suction
      }
    })
    return m
  }

  private maxLumen(): number {
    let m: number = 0
    this.lights.forEach((l: LightItem) => {
      if (l.lumen > m) {
        m = l.lumen
      }
    })
    return m
  }

  private maxSubsidy(): number {
    let m: number = 0
    this.trades.forEach((t: TradeItem) => {
      if (t.subsidy > m) {
        m = t.subsidy
      }
    })
    return m
  }

  private selName(): string {
    if (this.selectedItem === null) {
      return ''
    }
    return this.selectedItem.name
  }

  private selPrice(): number {
    if (this.selectedItem === null) {
      return 0
    }
    return this.selectedItem.price
  }

  private selCategory(): string {
    if (this.selectedItem === null) {
      return ''
    }
    return this.selectedItem.category
  }

  private selTag(): string {
    if (this.selectedItem === null) {
      return ''
    }
    return this.selectedItem.tag
  }

  private selQuality(): number {
    if (this.selectedItem === null) {
      return 0
    }
    return this.selectedItem.quality
  }

  private selSold(): number {
    if (this.selectedItem === null) {
      return 0
    }
    return this.selectedItem.sold
  }

  build() {
    Stack() {
      Column() {
        this.headerBar()
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabMarket()
            } else if (this.currentTab === 1) {
              this.tabSpeaker()
            } else if (this.currentTab === 2) {
              this.tabRobot()
            } else if (this.currentTab === 3) {
              this.tabLight()
            } else if (this.currentTab === 4) {
              this.tabTrade()
            } else if (this.currentTab === 5) {
              this.tabMessage()
            } else {
              this.tabMine()
            }
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 10, bottom: 10 })
        }
        .layoutWeight(1)
        .width('100%')
        .scrollBar(BarState.Off)
        this.bottomBar()
      }
      .width('100%')
      .height('100%')

      if (this.showPublish) {
        this.modalPublish()
      }
      if (this.showTrade) {
        this.modalTrade()
      }
      if (this.showWarranty) {
        this.modalWarranty()
      }
      if (this.showDelete) {
        this.modalDelete()
      }
      if (this.showDetail) {
        this.modalDetail()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FA')
  }

  @Builder
  headerBar() {
    Column() {
      Row() {
        Column() {
          Text('智能家电数码港')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
        }
        Column() {
          Row() {
            Text('搜索智能设备 / 型号 / 品牌')
              .fontSize(12)
              .fontColor('#90A4AE')
          }
          .width('100%')
          .height(30)
          .backgroundColor('#FFFFFF')
          .borderRadius(15)
          .justifyContent(FlexAlign.Start)
          .padding({ left: 14 })
        }
        .layoutWeight(1)
        .margin({ left: 12, right: 12 })
        Column() {
          Text('消息')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .onClick(() => {
          this.currentTab = 5
        })
      }
      .width('100%')
      .height(50)
      .padding({ left: 14, right: 14 })
      .alignItems(VerticalAlign.Center)

      Scroll() {
        Row() {
          ForEach(this.categories, (c: string) => {
            Text(c)
              .fontSize(12)
              .fontColor('#E1F5FE')
              .backgroundColor('rgba(255,255,255,0.12)')
              .borderRadius(12)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .margin({ right: 8 })
              .onClick(() => {
                this.selectedCategory = 0
              })
          })
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .padding({ left: 14, bottom: 8 })
    }
    .width('100%')
    .backgroundColor('#1565C0')
  }

  @Builder
  bottomBar() {
    Row() {
      ForEach(this.tabs, (tab: string, index: number) => {
        Column() {
          Column()
            .width(index === this.currentTab ? 18 : 6)
            .height(3)
            .borderRadius(2)
            .backgroundColor(index === this.currentTab ? '#00E5FF' : 'rgba(255,255,255,0.3)')
          Text(tab)
            .fontSize(11)
            .fontColor(index === this.currentTab ? '#00E5FF' : 'rgba(255,255,255,0.6)')
            .margin({ top: 5 })
        }
        .layoutWeight(1)
        .height(54)
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.currentTab = index
        })
      })
    }
    .width('100%')
    .backgroundColor('#0D47A1')
  }

  @Builder
  tabMarket() {
    Column() {
      Row() {
        Column() {
          Text('全屋智能节')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('套购满 3000 减 300 · 免费上门设计')
            .fontSize(11)
            .fontColor('#B3E5FC')
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Column() {
          Text('换新')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1565C0')
          Text('最高补 800')
            .fontSize(10)
            .fontColor('#FFFFFF')
            .margin({ top: 2 })
        }
        .width(72)
        .height(72)
        .backgroundColor('#00E5FF')
        .borderRadius(14)
        .justifyContent(FlexAlign.Center)
        .scale({ x: 1.05, y: 1.05 })
        .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
        .onClick(() => {
          this.showTrade = true
        })
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#0D47A1')
      .borderRadius(14)
      .alignItems(VerticalAlign.Center)

      Row() {
        Column() {
          Text('24')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1565C0')
          Text('在售设备')
            .fontSize(11)
            .fontColor('#90A4AE')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .height(64)
        .backgroundColor('#FFFFFF')
        .borderRadius(10)
        .margin({ top: 10 })

        Column() {
          Text('8432')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1565C0')
          Text('最高销量')
            .fontSize(11)
            .fontColor('#90A4AE')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .height(64)
        .backgroundColor('#FFFFFF')
        .borderRadius(10)
        .margin({ top: 10, left: 8 })

        Column() {
          Text('96%')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1565C0')
          Text('设备好评')
            .fontSize(11)
            .fontColor('#90A4AE')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .height(64)
        .backgroundColor('#FFFFFF')
        .borderRadius(10)
        .margin({ top: 10, left: 8 })

        Column() {
          Text('延保')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('低至 29 元')
            .fontSize(11)
            .fontColor('#B3E5FC')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .height(64)
        .backgroundColor('#0097A7')
        .borderRadius(10)
        .margin({ top: 10, left: 8 })
        .onClick(() => {
          this.showWarranty = true
        })
      }
      .width('100%')

      Column() {
        ForEach(this.digitals, (d: DigitalItem) => {
          Row() {
            Column()
              .width(86)
              .height(86)
              .backgroundColor('#E3F2FD')
              .borderRadius(10)
            Column() {
              Text(d.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#263238')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Row() {
                Text(d.category)
                  .fontSize(10)
                  .fontColor('#1565C0')
                  .backgroundColor('#E3F2FD')
                  .borderRadius(4)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                Text(d.tag)
                  .fontSize(10)
                  .fontColor('#FFFFFF')
                  .backgroundColor('#0097A7')
                  .borderRadius(4)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .margin({ left: 6 })
                  .scale({ x: 1.06, y: 1.06 })
                  .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
              }
              .margin({ top: 6 })

              Row() {
                Text('口碑')
                  .fontSize(10)
                  .fontColor('#90A4AE')
                Stack({ alignContent: Alignment.Start }) {
                  Column()
                    .width('100%')
                    .height(6)
                    .backgroundColor('#ECEFF1')
                    .borderRadius(3)
                  Column()
                    .width(d.quality + '%')
                    .height(6)
                    .backgroundColor('#1565C0')
                    .borderRadius(3)
                }
                .width(90)
                .margin({ left: 6 })
                Text(d.quality + '%')
                  .fontSize(10)
                  .fontColor('#1565C0')
                  .fontWeight(FontWeight.Bold)
                  .margin({ left: 6 })
              }
              .margin({ top: 8 })

              Row() {
                Text('¥' + d.price)
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1565C0')
                Text(d.sold + '条评价')
                  .fontSize(11)
                  .fontColor('#90A4AE')
                  .margin({ left: 8 })
                Column().layoutWeight(1)
                Text('看详情')
                  .fontSize(11)
                  .fontColor('#FFFFFF')
                  .backgroundColor('#1565C0')
                  .borderRadius(10)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              }
              .width('100%')
              .alignItems(VerticalAlign.Center)
              .margin({ top: 8 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 10 })
          }
          .width('100%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(10)
          .margin({ top: 10 })
          .alignItems(VerticalAlign.Center)
          .onClick(() => {
            this.selectedItem = d
            this.showDetail = true
          })
        })
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  tabSpeaker() {
    Column() {
      Column() {
        Row() {
          Text('音箱功率榜 (W)')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('瓦数越高声压越强')
            .fontSize(10)
            .fontColor('#B3E5FC')
            .margin({ left: 10 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          ForEach(this.speakers, (s: SpeakerItem) => {
            Column() {
              Column()
                .width(15)
                .height(s.watt / this.maxWatt() * 90)
                .backgroundColor('#00E5FF')
                .borderRadius({ topLeft: 3, topRight: 3 })
            }
            .height(94)
            .justifyContent(FlexAlign.End)
            .margin({ right: 5 })
          })
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#263238')
      .borderRadius(12)
      .margin({ top: 4 })

      Column() {
        ForEach(this.speakers, (s: SpeakerItem) => {
          Row() {
            Column() {
              Text(s.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#263238')
              Row() {
                Text(s.brand)
                  .fontSize(10)
                  .fontColor('#1565C0')
                  .backgroundColor('#E3F2FD')
                  .borderRadius(4)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                Text(s.channels)
                  .fontSize(10)
                  .fontColor('#78909C')
                  .margin({ left: 8 })
                Text(s.watt + 'W')
                  .fontSize(12)
                  .fontColor('#0097A7')
                  .fontWeight(FontWeight.Bold)
                  .margin({ left: 8 })
              }
              .margin({ top: 5 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Column() {
              Text('¥' + s.price)
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1565C0')
              Text('加延保')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .backgroundColor('#0097A7')
                .borderRadius(10)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .margin({ top: 6 })
                .onClick(() => {
                  this.selectedYears = 0
                  this.showWarranty = true
                })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .margin({ top: 10 })
          .alignItems(VerticalAlign.Center)
        })
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  tabRobot() {
    Column() {
      Column() {
        Text('吸力参数对比 (Pa)')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
        Text('虚标吸力一律不上架 · 实验室实测')
          .fontSize(10)
          .fontColor('#90A4AE')
          .margin({ top: 4 })
        Row() {
          ForEach(this.robots, (r: RobotItem) => {
            Column() {
              Column()
                .width(18)
                .height(r.suction / this.maxSuction() * 86)
                .backgroundColor('#1565C0')
                .borderRadius({ topLeft: 3, topRight: 3 })
            }
            .height(90)
            .justifyContent(FlexAlign.End)
            .margin({ right: 5 })
          })
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ top: 4 })

      Column() {
        ForEach(this.robots, (r: RobotItem) => {
          Column() {
            Row() {
              Column() {
                Text(r.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#263238')
                Row() {
                  Text(r.suction + 'Pa')
                    .fontSize(10)
                    .fontColor('#FFFFFF')
                    .backgroundColor('#1565C0')
                    .borderRadius(4)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  Text(r.mapNav)
                    .fontSize(10)
                    .fontColor('#78909C')
                    .backgroundColor('#F5F7FA')
                    .borderRadius(4)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .margin({ left: 6 })
                }
                .margin({ top: 5 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)

              Text('¥' + r.price)
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1565C0')
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Row() {
              Text('续航')
                .fontSize(10)
                .fontColor('#90A4AE')
              Stack({ alignContent: Alignment.Start }) {
                Column()
                  .width('100%')
                  .height(8)
                  .backgroundColor('#ECEFF1')
                  .borderRadius(4)
                Column()
                  .width(r.battery / 220 * 100 + '%')
                  .height(8)
                  .backgroundColor('#00E5FF')
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .margin({ left: 8 })
              Text(r.battery + 'min')
                .fontSize(10)
                .fontColor('#0097A7')
                .fontWeight(FontWeight.Bold)
                .margin({ left: 8 })
            }
            .width('100%')
            .margin({ top: 10 })
            .alignItems(VerticalAlign.Center)

            Row() {
              Text('免息 12 期')
                .fontSize(10)
                .fontColor('#0097A7')
              Text('以旧换新补 300')
                .fontSize(10)
                .fontColor('#1565C0')
                .margin({ left: 10 })
              Column().layoutWeight(1)
              Text('立即换新')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .backgroundColor('#0D47A1')
                .borderRadius(10)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .scale({ x: 1.05, y: 1.05 })
                .animation({ duration: 800, iterations: -1, curve: Curve.EaseInOut })
                .onClick(() => {
                  this.selectedCond = 0
                  this.showTrade = true
                })
            }
            .width('100%')
            .margin({ top: 8 })
            .alignItems(VerticalAlign.Center)
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .margin({ top: 10 })
        })
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  tabLight() {
    Column() {
      Column() {
        Row() {
          Text('亮度参数 (lm)')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('支持全屋联动')
            .fontSize(10)
            .fontColor('#B3E5FC')
            .margin({ left: 10 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Row() {
          ForEach(this.lights, (l: LightItem) => {
            Column() {
              Column()
                .width(16)
                .height(l.lumen / this.maxLumen() * 80)
                .backgroundColor('#FFD54F')
                .borderRadius({ topLeft: 3, topRight: 3 })
            }
            .height(84)
            .justifyContent(FlexAlign.End)
            .margin({ right: 5 })
          })
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#1565C0')
      .borderRadius(12)
      .margin({ top: 4 })

      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(this.lights, (l: LightItem) => {
          Column() {
            Column()
              .width('100%')
              .height(72)
              .backgroundColor('#FFF8E1')
              .borderRadius(10)
            Text(l.name)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#263238')
              .margin({ top: 6 })
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            Text(l.colorTemp + ' · ' + l.lumen + 'lm')
              .fontSize(10)
              .fontColor('#90A4AE')
              .margin({ top: 2 })
            Row() {
              Text('¥' + l.price)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1565C0')
              Text('适配' + l.rooms + '房间')
                .fontSize(10)
                .fontColor('#78909C')
                .margin({ left: 6 })
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
            .margin({ top: 6 })
            Text('配对购买')
              .fontSize(11)
              .fontColor('#FFFFFF')
              .backgroundColor('#0097A7')
              .borderRadius(12)
              .padding({ left: 12, right: 12, top: 4, bottom: 4 })
              .margin({ top: 6 })
              .onClick(() => {
                this.qty = 1
                this.showWarranty = true
              })
          }
          .width('48%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(10)
          .margin({ top: 10 })
          .alignItems(HorizontalAlign.Start)
        })
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  tabTrade() {
    Column() {
      Column() {
        Row() {
          Text('国家补贴 + 厂补叠加')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('最高省 800')
            .fontSize(11)
            .fontColor('#00E5FF')
            .margin({ left: 10 })
            .scale({ x: 1.08, y: 1.08 })
            .animation({ duration: 600, iterations: -1, curve: Curve.EaseInOut })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .alignItems(VerticalAlign.Center)
        Row() {
          ForEach(this.trades, (t: TradeItem) => {
            Column() {
              Column()
                .width(14)
                .height(t.subsidy / this.maxSubsidy() * 72)
                .backgroundColor('#00E5FF')
                .borderRadius({ topLeft: 3, topRight: 3 })
            }
            .height(76)
            .justifyContent(FlexAlign.End)
            .margin({ right: 5 })
          })
        }
        .width('100%')
        .margin({ top: 10 })
        Text('各品类换新补贴金额 (元)')
          .fontSize(10)
          .fontColor('#B3E5FC')
          .margin({ top: 6 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#0D47A1')
      .borderRadius(12)
      .margin({ top: 4 })

      Column() {
        ForEach(this.trades, (t: TradeItem) => {
          Row() {
            Column() {
              Text(t.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#263238')
              Row() {
                Text(t.brand)
                  .fontSize(10)
                  .fontColor('#1565C0')
                  .backgroundColor('#E3F2FD')
                  .borderRadius(4)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                Text('已用 ' + t.year + ' 年')
                  .fontSize(10)
                  .fontColor('#78909C')
                  .margin({ left: 8 })
              }
              .margin({ top: 4 })
              Row() {
                Text('机龄折旧')
                  .fontSize(10)
                  .fontColor('#90A4AE')
                Stack({ alignContent: Alignment.Start }) {
                  Column()
                    .width('100%')
                    .height(6)
                    .backgroundColor('#ECEFF1')
                    .borderRadius(3)
                  Column()
                    .width((100 - t.year * 8) + '%')
                    .height(6)
                    .backgroundColor('#0097A7')
                    .borderRadius(3)
                }
                .width(80)
                .margin({ left: 6 })
              }
              .margin({ top: 6 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Column() {
              Text('补 ¥' + t.subsidy)
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1565C0')
              Text('旧机估值 ¥' + t.oldVal)
                .fontSize(10)
                .fontColor('#90A4AE')
                .margin({ top: 2 })
              Text('去换新')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .backgroundColor('#1565C0')
                .borderRadius(10)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .margin({ top: 6 })
                .onClick(() => {
                  this.selectedCond = 0
                  this.showTrade = true
                })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .margin({ top: 10 })
          .alignItems(VerticalAlign.Center)
        })
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  tabMessage() {
    Column() {
      Row() {
        Text('消息中心')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
        Text('6条未读')
          .fontSize(11)
          .fontColor('#1565C0')
          .margin({ left: 8 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ top: 4 })

      Column() {
        ForEach(this.messages, (m: MessageItem) => {
          Row() {
            Column() {
              Text(m.title.substring(0, 1))
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .width(44)
            .height(44)
            .backgroundColor('#0D47A1')
            .borderRadius(22)
            .justifyContent(FlexAlign.Center)

            Column() {
              Row() {
                Text(m.title)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#263238')
                if (m.unread > 0) {
                  Text(m.unread + '')
                    .fontSize(10)
                    .fontColor('#FFFFFF')
                    .backgroundColor('#1565C0')
                    .borderRadius(8)
                    .padding({ left: 6, right: 6, top: 1, bottom: 1 })
                    .margin({ left: 6 })
                    .scale({ x: 1.1, y: 1.1 })
                    .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
                }
              }
              Text(m.content)
                .fontSize(12)
                .fontColor('#90A4AE')
                .margin({ top: 3 })
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 10 })

            Text(m.time)
              .fontSize(11)
              .fontColor('#B0BEC5')
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .margin({ top: 10 })
          .alignItems(VerticalAlign.Center)
          .onClick(() => {
            m.unread = 0
          })
        })
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  tabMine() {
    Column() {
      Row() {
        Column()
          .width(64)
          .height(64)
          .backgroundColor('#B3E5FC')
          .borderRadius(32)
        Column() {
          Text('全屋智能玩家')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
          Text('PLUS 会员 · 已联动 26 台设备')
            .fontSize(11)
            .fontColor('#90A4AE')
            .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        .padding({ left: 12 })
        Text('我的场景')
          .fontSize(12)
          .fontColor('#FFFFFF')
          .backgroundColor('#0097A7')
          .borderRadius(12)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .scale({ x: 1.05, y: 1.05 })
          .animation({ duration: 800, iterations: -1, curve: Curve.EaseInOut })
          .onClick(() => {
            this.showWarranty = true
          })
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ top: 4 })
      .alignItems(VerticalAlign.Center)

      Row() {
        Column() {
          Text('18')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
          Text('在途设备')
            .fontSize(11)
            .fontColor('#90A4AE')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text('4')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
          Text('延保中')
            .fontSize(11)
            .fontColor('#90A4AE')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text('2')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
          Text('换新单')
            .fontSize(11)
            .fontColor('#90A4AE')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text('37')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
          Text('收藏夹')
            .fontSize(11)
            .fontColor('#90A4AE')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 14, bottom: 14 })
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ top: 10 })

      Column() {
        ForEach(['我的订单', '换新进度', '延保管理', '智能场景', '客服中心', '清除浏览记录'], (m: string, idx: number) => {
          Row() {
            Text(m)
              .fontSize(14)
              .fontColor('#263238')
            Column().layoutWeight(1)
            Text('>')
              .fontSize(14)
              .fontColor('#B0BEC5')
          }
          .width('100%')
          .padding({ top: 14, bottom: 14 })
          .border({ width: 1, color: '#F5F7FA' })
          .onClick(() => {
            if (idx === 5) {
              this.showDelete = true
            }
          })
        })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .padding({ left: 14, right: 14 })
      .margin({ top: 10 })
    }
    .width('100%')
  }

  @Builder
  modalPublish() {
    Column() {
      Column().layoutWeight(1).width('100%')
      Column() {
        Row() {
          Text('发布二手智能设备')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
          Text('关闭')
            .fontSize(13)
            .fontColor('#90A4AE')
            .padding(6)
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)

        Column() {
          Text('设备名称')
            .fontSize(13)
            .fontColor('#78909C')
          TextInput({ placeholder: '例如:扫地机器人 S7 用了半年' })
            .height(40)
            .fontSize(13)
            .backgroundColor('#F5F7FA')
            .borderRadius(8)
            .margin({ top: 6 })
            .onChange((value: string) => {
              this.inputName = value
            })
        }
        .width('100%')
        .margin({ top: 16 })
        .alignItems(HorizontalAlign.Start)

        Column() {
          Text('设备品类')
            .fontSize(13)
            .fontColor('#78909C')
          Row() {
            ForEach(this.categories, (c: string, idx: number) => {
              Text(c)
                .fontSize(12)
                .fontColor(this.selectedCategory === idx ? '#FFFFFF' : '#78909C')
                .backgroundColor(this.selectedCategory === idx ? '#1565C0' : '#F5F7FA')
                .borderRadius(14)
                .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                .margin({ right: 8, top: 8 })
                .onClick(() => {
                  this.selectedCategory = idx
                })
            })
          }
          .width('100%')
        }
        .width('100%')
        .margin({ top: 16 })
        .alignItems(HorizontalAlign.Start)

        Column() {
          Text('设备品牌')
            .fontSize(13)
            .fontColor('#78909C')
          Row() {
            ForEach(this.brands, (b: string, idx: number) => {
              Text(b)
                .fontSize(12)
                .fontColor(this.selectedBrand === idx ? '#FFFFFF' : '#78909C')
                .backgroundColor(this.selectedBrand === idx ? '#0097A7' : '#F5F7FA')
                .borderRadius(8)
                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                .margin({ right: 8, top: 8 })
                .onClick(() => {
                  this.selectedBrand = idx
                })
            })
          }
          .width('100%')
        }
        .width('100%')
        .margin({ top: 16 })
        .alignItems(HorizontalAlign.Start)

        Text('预估回收价:¥' + ((this.selectedCategory + 1) * 120 + (this.selectedBrand + 1) * 40))
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0097A7')
          .margin({ top: 18 })
          .scale({ x: 1.04, y: 1.04 })
          .animation({ duration: 600, iterations: -1, curve: Curve.EaseInOut })

        Text('确认发布')
          .fontSize(15)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)
          .width('100%')
          .height(44)
          .textAlign(TextAlign.Center)
          .backgroundColor('#1565C0')
          .borderRadius(22)
          .margin({ top: 18 })
          .onClick(() => {
            this.showPublish = false
          })
      }
      .width('100%')
      .constraintSize({ maxHeight: '80%' })
      .backgroundColor('#FFFFFF')
      .borderRadius({ topLeft: 16, topRight: 16 })
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.5)')
    .onClick(() => {
      this.showPublish = false
    })
  }

  @Builder
  modalTrade() {
    Column() {
      Column().layoutWeight(1).width('100%')
      Column() {
        Row() {
          Text('以旧换新估价')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#263238')
          Text('补 ¥' + ((this.selectedBrand + 1) * 90 + (this.selectedCond + 1) * 50))
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0097A7')
            .scale({ x: 1.05, y: 1.05 })
            .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .alignItems(VerticalAlign.Center)

        Column() {
          Text('品牌')
            .fontSize(13)
            .fontColor('#78909C')
          Row() {
            ForEach(this.brands, (b: string, idx: number) => {
              Text(b)
                .fontSize(12)
                .fontColor(this.selectedBrand === idx ? '#FFFFFF' : '#78909C')
                .backgroundColor(this.selectedBrand === idx ? '#1565C0' : '#F5F7FA')
                .borderRadius(8)
                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                .margin({ right: 8, top: 10 })
                .onClick(() => {
                  this.selectedBrand = idx
                })
            })
          }
          .width('100%')
        }
        .width('100%')
        .margin({ top: 16 })
        .alignItems(HorizontalAlign.Start)

        Column() {
          Text('成色')
            .fontSize(13)
            .fontColor('#78909C')
          Row() {
            ForEach(this.conds, (c: string, idx: number) => {
              Text(c)
                .fontSize(12)
                .fontColor(this.selectedCond === idx ? '#FFFFFF' : '#78909C')
                .backgroundColor(this.selectedCond === idx ? '#0097A7' : '#F5F7FA')
                .borderRadius(8)
                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                .margin({ right: 8, top: 10 })
                .onClick(() => {
                  this.selectedCond = idx
                })
            })
          }
          .width('100%')
        }
        .width('100%')
        .margin({ top: 16 })
        .alignItems(HorizontalAlign.Start)

        Text('上门取件 · 旧机款直接抵扣新机款')
          .fontSize(12)
          .fontColor('#1565C0')
          .margin({ top: 14 })

        Row() {
          Text('再想想')
            .fontSize(14)
            .fontColor('#78909C')
            .layoutWeight(1)
            .height(42)
            .textAlign(TextAlign.Center)
            .backgroundColor('#F5F7FA')
            .borderRadius(21)
            .onClick(() => {
              this.showTrade = false
            })
          Text('预约上门')
            .fontSize(14)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .height(42)
            .textAlign(TextAlign.Center)
            .backgroundColor('#1565C0')
            .borderRadius(21)
            .margin({ left: 10 })
            .onClick(() => {
              this.showTrade = false
            })
        }
        .width('100%')
        .margin({ top: 18 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '80%' })
      .backgroundColor('#FFFFFF')
      .borderRadius({ topLeft: 16, topRight: 16 })
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.5)')
    .onClick(() => {
      this.showTrade = false
    })
  }

  @Builder
  modalWarranty() {
    Column() {
      Column() {
        Text('购买延保服务')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
          .margin({ top: 18 })

        Column() {
          Text('保障方案')
            .fontSize(13)
            .fontColor('#78909C')
          Row() {
            ForEach(this.warrantyOptions, (w: string, idx: number) => {
              Text(w)
                .fontSize(12)
                .fontColor(this.selectedYears === idx ? '#FFFFFF' : '#78909C')
                .backgroundColor(this.selectedYears === idx ? '#1565C0' : '#F5F7FA')
                .borderRadius(14)
                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                .margin({ right: 8, top: 10 })
                .onClick(() => {
                  this.selectedYears = idx
                })
            })
          }
          .width('100%')
        }
        .width('100%')
        .margin({ top: 16 })
        .alignItems(HorizontalAlign.Start)

        Row() {
          Text('保障台数')
            .fontSize(13)
            .fontColor('#78909C')
          Column().layoutWeight(1)
          Row() {
            Text('-')
              .fontSize(15)
              .fontColor('#78909C')
              .width(28)
              .height(28)
              .textAlign(TextAlign.Center)
              .backgroundColor('#F5F7FA')
              .borderRadius(6)
              .onClick(() => {
                if (this.qty > 1) {
                  this.qty -= 1
                }
              })
            Text(this.qty + '')
              .fontSize(14)
              .fontColor('#263238')
              .width(36)
              .height(28)
              .textAlign(TextAlign.Center)
            Text('+')
              .fontSize(15)
              .fontColor('#78909C')
              .width(28)
              .height(28)
              .textAlign(TextAlign.Center)
              .backgroundColor('#F5F7FA')
              .borderRadius(6)
              .onClick(() => {
                if (this.qty < 12) {
                  this.qty += 1
                }
              })
          }
        }
        .width('100%')
        .margin({ top: 16 })
        .alignItems(VerticalAlign.Center)

        Text('合计:¥' + ((this.selectedYears + 1) * 29 * this.qty))
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1565C0')
          .margin({ top: 18 })
          .scale({ x: 1.04, y: 1.04 })
          .animation({ duration: 650, iterations: -1, curve: Curve.EaseInOut })

        Text('确认投保')
          .fontSize(14)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)
          .width('100%')
          .height(42)
          .textAlign(TextAlign.Center)
          .backgroundColor('#1565C0')
          .borderRadius(21)
          .margin({ top: 20, bottom: 18 })
          .onClick(() => {
            this.showWarranty = false
          })
      }
      .width('86%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding({ left: 18, right: 18 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.5)')
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.showWarranty = false
    })
  }

  @Builder
  modalDelete() {
    Column() {
      Column() {
        Text('!')
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width(56)
          .height(56)
          .textAlign(TextAlign.Center)
          .backgroundColor('#1565C0')
          .borderRadius(28)
        Text('确认移除')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#263238')
          .margin({ top: 14 })
        Text('移除后设备将解除账号绑定,是否继续?')
          .fontSize(13)
          .fontColor('#90A4AE')
          .margin({ top: 8 })

        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor('#78909C')
            .layoutWeight(1)
            .height(40)
            .textAlign(TextAlign.Center)
            .backgroundColor('#F5F7FA')
            .borderRadius(20)
            .onClick(() => {
              this.showDelete = false
            })
          Text('确认移除')
            .fontSize(14)
            .fontColor('#FFFFFF')
            .layoutWeight(1)
            .height(40)
            .textAlign(TextAlign.Center)
            .backgroundColor('#1565C0')
            .borderRadius(20)
            .margin({ left: 10 })
            .onClick(() => {
              this.showDelete = false
              this.showDetail = false
            })
        }
        .width('100%')
        .margin({ top: 22, bottom: 20 })
      }
      .width('78%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.5)')
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.showDelete = false
    })
  }

  @Builder
  modalDetail() {
    Row() {
      Scroll() {
        Column() {
          Column()
            .width('100%')
            .height(180)
            .backgroundColor('#B3E5FC')
            .borderRadius({ topLeft: 16, bottomLeft: 16 })

          Column() {
            Row() {
              Text('¥' + this.selPrice())
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1565C0')
              Text('补贴后再省 300')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .backgroundColor('#0097A7')
                .borderRadius(10)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .margin({ left: 10 })
                .scale({ x: 1.06, y: 1.06 })
                .animation({ duration: 700, iterations: -1, curve: Curve.EaseInOut })
            }
            .alignItems(VerticalAlign.Center)

            Text(this.selName())
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#263238')
              .margin({ top: 8 })

            Row() {
              Text(this.selCategory())
                .fontSize(10)
                .fontColor('#1565C0')
                .backgroundColor('#E3F2FD')
                .borderRadius(4)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              Text(this.selTag())
                .fontSize(10)
                .fontColor('#FFFFFF')
                .backgroundColor('#0D47A1')
                .borderRadius(4)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .margin({ left: 6 })
            }
            .margin({ top: 8 })

            Column() {
              Text('口碑评分 ' + this.selQuality() + '%')
                .fontSize(12)
                .fontColor('#78909C')
              Stack({ alignContent: Alignment.Start }) {
                Column()
                  .width('100%')
                  .height(8)
                  .backgroundColor('#ECEFF1')
                  .borderRadius(4)
                Column()
                  .width(this.selQuality() + '%')
                  .height(8)
                  .backgroundColor('#1565C0')
                  .borderRadius(4)
              }
              .width('100%')
              .margin({ top: 6 })
            }
            .width('100%')
            .alignItems(HorizontalAlign.Start)
            .margin({ top: 14 })

            Row() {
              Text(this.selSold() + ' 条评价')
                .fontSize(12)
                .fontColor('#90A4AE')
              Text('全国联保')
                .fontSize(12)
                .fontColor('#90A4AE')
                .margin({ left: 12 })
              Text('免费安装')
                .fontSize(12)
                .fontColor('#0097A7')
                .margin({ left: 12 })
            }
            .margin({ top: 12 })

            Row() {
              Text('加延保')
                .fontSize(14)
                .fontColor('#0097A7')
                .fontWeight(FontWeight.Bold)
                .layoutWeight(1)
                .height(40)
                .textAlign(TextAlign.Center)
                .backgroundColor('#E0F7FA')
                .borderRadius(20)
                .onClick(() => {
                  this.selectedYears = 0
                  this.showWarranty = true
                })
              Text('以旧换新')
                .fontSize(14)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
                .layoutWeight(1)
                .height(40)
                .textAlign(TextAlign.Center)
                .backgroundColor('#1565C0')
                .borderRadius(20)
                .margin({ left: 10 })
                .onClick(() => {
                  this.selectedCond = 0
                  this.showTrade = true
                })
            }
            .width('100%')
            .margin({ top: 18 })

            Row() {
              Text('移除该设备')
                .fontSize(13)
                .fontColor('#1565C0')
                .padding({ top: 12 })
            }
            .width('100%')
            .onClick(() => {
              this.showDelete = true
            })
          }
          .width('100%')
          .padding(16)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
      }
      .width('78%')
      .height('100%')
      .backgroundColor('#FFFFFF')

      Column()
        .layoutWeight(1)
        .height('100%')
        .onClick(() => {
          this.showDetail = false
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.5)')
    .onClick(() => {
      this.showDetail = false
    })
  }
}


总结

在这里插入图片描述

本文围绕基于 HarmonyOS 6.1.1 的智能家电数码港应用进行了全面的源码级解析。从数据接口定义到组件状态管理,从页面骨架路由到各 Tab 的差异化布局,从柱状图可视化到弹窗模态交互,完整剖析了 ArkTS 声明式开发范式在重数据驱动型电商应用中的工程实践。应用通过六组强类型 interface 构建了严密的类型安全体系,通过 @State 响应式状态驱动了七个 Tab 视图和五种弹窗模态的联动渲染,通过 @Builder 装饰器实现了 UI 逻辑的高内聚低耦合拆分,这些设计共同构成了一个可维护、可扩展的鸿蒙原生应用架构。

在数据可视化层面,应用展现了 ArkUI 纯组件实现图表的灵活能力。音箱功率榜、机器人吸力对比、灯具亮度参数、换新补贴金额四组柱状图均采用 Column 高度归一化方案,通过"数据值/最大值*基准高度"的表达式动态计算柱体高度,配合 FlexAlign.End 底部对齐和 topLeft/topRight 圆角,无需引入任何第三方图表库即可实现专业级的数据可视化效果。口碑进度条和续航进度条采用双层 Stack 叠加方案,背景条与前景条的宽度差形成进度填充,方案简洁而高效。这些可视化技术在性能敏感的移动设备上具有天然优势,因为它们完全基于原生 UI 组件渲染,不存在 Canvas 绘制或 WebView 桥接的额外开销。

在交互体验层面,应用大量运用了 scale 配合 animation 的循环缩放动画来引导用户视觉焦点。从横幅换新按钮的 1.05 倍呼吸到补贴金额的 1.08 倍脉冲,从营销标签的 1.06 倍微缩放到未读红点的 1.1 倍跳动,不同缩放比例和动画时长(600ms 至 800ms)的组合为界面注入了层次丰富的动态感。弹窗模态的"遮罩点击关闭+内容阻止冒泡"模式、侧滑面板的 78% 宽度布局、步进器的边界检查逻辑、消息点击即已读的对象属性修改等细节,均体现了对移动端交互模式的深入理解和 ArkUI 事件系统的熟练运用。这些实践经验对于在 HarmonyOS 平台上构建高质量的商业级应用具有直接的参考价值。

Logo

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

更多推荐