技术引言

HarmonyOS 6.1.1 作为华为鸿蒙操作系统的最新演进版本,带来了更加成熟的分布式架构能力和更加丰富的原生开发框架。在这一版本中,HarmonyOS ArkTS API 24 提供了完整的声明式 UI 范式,开发者可以使用 TypeScript 强类型语言配合 @Component@State@Builder@Observed 等装饰器,以接近前端 React/Vue 的心智模型来构建高性能原生应用。ArkTS API 24 相较于早期版本,在状态管理粒度、组件复用机制、列表渲染性能以及动画调度方面均有显著增强,尤其适合构建数据密集型的电商类应用。

本文以一个名为"QQ潮流集市"的购物 App 为实战案例,深入剖析基于 HarmonyOS API 24 的 ArkTS 开发实践。该应用采用橙白电商风格设计语言,包含底部 4 Tab 导航、顶部 6 功能 Tab 切换、4 种弹框交互模式(加入购物车、地址编辑、删除确认、商品详情)、销量柱状图可视化、以及粒子飘浮特效动画。通过逐段拆解约 3500 行源代码,本文将系统讲解配色系统架构、数据模型设计、状态管理策略、组件间通信模式、弹框路由控制、以及自定义可视化组件的实现细节,为 HarmonyOS 原生电商开发提供完整的技术参考。


一、整体架构概览

在深入代码之前,我们首先通过架构图来理解整个应用的结构层次。

MarketPage 主入口 @Entry

底部4Tab路由层

首页 Home

分类 Category

购物车 Cart

我的 Mine

顶部6功能Tab路由

精选 FeaturedContent

闪购 FlashContent

品牌 BrandContent

优惠券 CouponContent

新品 NewContent

排行 RankContent

弹框路由层

加入购物车弹框

地址编辑弹框

删除确认弹框

商品详情弹框

粒子特效层

全局状态管理

cartItems 购物车数据

selectedProduct 当前选中商品

flashSeconds 闪购倒计时

particles 粒子数组

全局配色系统 COLORS

上图展示了应用从主入口 MarketPage 向下分发的三层路由结构:底部 Tab 路由负责首页/分类/购物车/我的四个主页面切换;首页内部再通过顶部功能 Tab 路由切换六个内容子组件;弹框路由则独立于页面切换,由四个布尔状态变量驱动。所有组件共享统一的 COLORS 配色系统,确保视觉一致性。


二、逐段代码深度解析

代码段1:配色系统接口与常量定义

interface ColorPalette {
  primary: string
  primaryDark: string
  primaryLight: string
  bg: string
  cardBg: string
  ink: string
  sub: string
  faint: string
  red: string
  green: string
  gold: string
  border: string
  mask: string
}

const COLORS: ColorPalette = {
  primary: '#FF6B35',
  primaryDark: '#E85D04',
  primaryLight: '#FFE5D9',
  bg: '#FFF8F3',
  cardBg: '#FFFFFF',
  ink: '#1A1A2E',
  sub: '#7A7A8C',
  faint: '#B8B8C4',
  red: '#E63946',
  green: '#06A77D',
  gold: '#FFB627',
  border: '#F0E6DE',
  mask: 'rgba(26,26,46,0.55)'
}

在这里插入图片描述

配色系统是整个应用的视觉基石。这里通过 interface ColorPalette 定义了一个包含 14 个颜色字段的接口,涵盖了主色调(primary/primaryDark/primaryLight)、背景色(bg/cardBg)、文字色阶(ink/sub/faint)、语义色(red/green/gold)、以及边框和遮罩色(border/mask)。这种将配色抽象为接口的设计模式,使得所有组件可以统一引用 COLORS.primary 这样的语义化常量,而非在代码中硬编码十六进制色值。

在 HarmonyOS ArkTS API 24 中,interface 定义的纯类型结构不会产生运行时开销,而 const 声明的对象常量则会在编译期被内联优化。这种组合既保证了类型安全,又不会影响渲染性能。主色 #FF6B35 是一种充满活力的橙色,搭配 #E85D04 深橙作为渐变终点色,营造出温暖且富有购物冲动的电商氛围。浅色 #FFE5D9 则用于选中态背景和标签底色,形成视觉层次。文字色阶采用深蓝灰 #1A1A2E 作为主文字色,搭配灰色系 #7A7A8C#B8B8C4 形成三级灰度,确保信息层级的清晰可读性。


代码段2:元信息接口集合

interface ProductCategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface ProductStatusMeta {
  label: string
  color: string
  bg: string
  icon: string
}

interface KingKongEntry {
  label: string
  icon: string
  color: string
  bg: string
}

interface BannerInfo {
  title: string
  sub: string
  tag: string
  price: string
  bg: string
  icon: string
}

interface CouponMeta {
  title: string
  desc: string
  value: string
  color: string
  bg: string
  expireDate: string
}

interface ParticleDot {
  x: number
  y: number
  size: number
  color: string
  speed: number
  phase: number
  icon: string
}

interface BrandMeta {
  name: string
  slogan: string
  icon: string
  color: string
  bg: string
  fans: string
}

interface SpecOption {
  label: string
  color: string
  bg: string
}

interface AddressInfo {
  name: string
  phone: string
  region: string
  detail: string
  tag: string
}

interface TimeLineItem {
  time: string
  title: string
  desc: string
  icon: string
  color: string
}

interface RankMedal {
  label: string
  color: string
  bg: string
}

在这里插入图片描述

这一组接口定义了应用中所有业务实体的数据结构。在 HarmonyOS ArkTS API 24 的开发范式中,interface 是定义数据形状的标准方式,它不生成运行时代码,仅用于编译期类型检查。这种设计确保了从数据定义到 UI 渲染的全链路类型安全。

值得注意的是这些接口的粒度划分策略。ProductCategoryMetaProductStatusMeta 分别描述商品分类和状态的元信息,它们将显示文字、图标、前景色、背景色打包为一个整体,使得在 UI 层只需通过一次查找即可获取渲染所需的全部信息。ParticleDot 接口则是粒子动画系统的核心数据结构,包含位置坐标(x/y)、尺寸(size)、运动参数(speed/phase)和显示图标(icon),是一个典型的帧动画状态快照。KingKongEntry 接口被复用于金刚区入口和底部导航栏标签,体现了接口设计的复用原则。AddressInfo 包含完整的收货地址字段,支持地址编辑弹框的双向数据绑定。TimeLineItem 描述新品首发时间轴的每一条目,将时间、标题、描述、图标和主题色组织在一起。


代码段3:可观察数据模型 ProductItem

@Observed
class ProductItem {
  id: number = 0
  name: string = ''
  category: string = ''
  icon: string = ''
  price: number = 0
  originalPrice: number = 0
  salesCount: number = 0
  stock: number = 0
  rating: number = 0
  reviewCount: number = 0
  status: string = '在售'
  tags: string[] = []
  desc: string = ''
  brand: string = ''
  shop: string = ''
  freeShipping: boolean = false
  coverColor: string = ''

  constructor(id: number, name: string, category: string, icon: string, price: number,
    originalPrice: number, salesCount: number, stock: number, rating: number,
    reviewCount: number, status: string, tags: string[], desc: string, brand: string,
    shop: string, freeShipping: boolean, coverColor: string) {
    this.id = id
    this.name = name
    this.category = category
    this.icon = icon
    this.price = price
    this.originalPrice = originalPrice
    this.salesCount = salesCount
    this.stock = stock
    this.rating = rating
    this.reviewCount = reviewCount
    this.status = status
    this.tags = tags
    this.desc = desc
    this.brand = brand
    this.shop = shop
    this.freeShipping = freeShipping
    this.coverColor = coverColor
  }
}

在这里插入图片描述

@Observed 装饰器是 HarmonyOS ArkTS API 24 状态管理系统的核心之一。被 @Observed 标记的类,其实例属性的变化可以被框架追踪。当这类对象作为 @State 的值被组件持有并传入子组件时,属性级别的修改会精确触发依赖该属性的 UI 区域更新,而非整组件重绘。

ProductItem 包含了 17 个字段,覆盖了电商商品的全部核心维度:基础信息(id/name/category/icon/desc)、价格信息(price/originalPrice)、销售数据(salesCount/stock/reviewCount)、评价信息(rating)、状态信息(status/tags)、品牌信息(brand/shop/freeShipping)、以及视觉信息(coverColor)。构造函数采用全参数注入模式,确保每个商品实例在创建时即处于完整状态,避免可选属性带来的空值风险。coverColor 字段特别值得注意——它存储的是商品封面的背景色,由于本应用使用 Emoji 图标代替真实商品图片,coverColor 为每个商品提供了差异化的视觉背景,使得瀑布流列表中的商品卡具有色彩节奏感。tags 数组字段用于商品标签展示(如"爆款"“降噪”“40h续航”),在详情弹框和列表卡中均被渲染为彩色标签。


代码段4:可观察购物车模型 CartItem

@Observed
class CartItem {
  id: number = 0
  productName: string = ''
  icon: string = ''
  price: number = 0
  quantity: number = 0
  spec: string = ''
  shop: string = ''
  selected: boolean = false
  coverColor: string = ''

  constructor(id: number, productName: string, icon: string, price: number,
    quantity: number, spec: string, shop: string, selected: boolean, coverColor: string) {
    this.id = id
    this.productName = productName
    this.icon = icon
    this.price = price
    this.quantity = quantity
    this.spec = spec
    this.shop = shop
    this.selected = selected
    this.coverColor = coverColor
  }
}

CartItem 是购物车业务的核心数据模型,同样使用 @Observed 装饰。与 ProductItem 不同的是,CartItem 是一个可变的运行时数据结构——用户会频繁修改选中状态(selected)和数量(quantity),这些修改必须实时反映到 UI 上。

selected 布尔字段是购物车选中逻辑的基础。当用户点击商品前的圆形勾选框时,该字段在 true 和 false 之间切换。由于 @Observed 的追踪机制,该变化会立即触发勾选框的背景色和图标更新。quantity 字段同理,加减按钮的点击直接修改该值,UI 上的数字显示和底部的合计金额同步刷新。spec 字段存储用户选择的规格信息(如"L / 落日橘"),在加入购物车时从规格选项面板获取。coverColor 字段被冗余存储在购物车项中,避免在购物车列表渲染时需要回查 ProductItem 的映射关系,这是一种典型的空间换时间优化策略。构造函数的参数顺序与字段声明顺序保持一致,便于代码维护和审查。在 addToCart 方法中可以看到,当商品已在购物车中时,仅增加 quantity 而非创建新实例,这保证了同一商品在购物车中的唯一性。


代码段5:配置映射表与金刚区入口

const STATUS_MAP: Record<string, ProductStatusMeta> = {
  '在售': { label: '在售', color: '#06A77D', bg: 'rgba(6,167,125,0.12)', icon: '✓' },
  '预售': { label: '预售', color: '#FFB627', bg: 'rgba(255,182,39,0.15)', icon: '⏰' },
  '缺货': { label: '缺货', color: '#B8B8C4', bg: 'rgba(184,184,196,0.15)', icon: '✕' },
  '闪购': { label: '闪购', color: '#E63946', bg: 'rgba(230,57,70,0.12)', icon: '⚡' }
}

const CATEGORY_MAP: Record<string, ProductCategoryMeta> = {
  '数码': { label: '数码', icon: '📱', color: '#FF6B35', bg: '#FFE5D9' },
  '服饰': { label: '服饰', icon: '👕', color: '#E85D04', bg: '#FFEDE4' },
  '美妆': { label: '美妆', icon: '💄', color: '#E63946', bg: '#FDE3E5' },
  '家居': { label: '家居', icon: '🛋️', color: '#06A77D', bg: '#DDF3EC' },
  '食品': { label: '食品', icon: '🍪', color: '#FFB627', bg: '#FFF1D6' },
  '运动': { label: '运动', icon: '🏀', color: '#4361EE', bg: '#E2E7FD' }
}

const KINGKONG_ENTRIES: KingKongEntry[] = [
  { label: '限时秒杀', icon: '⚡', color: '#E63946', bg: '#FDE3E5' },
  { label: '品牌闪购', icon: '🏷️', color: '#E85D04', bg: '#FFEDE4' },
  { label: '领券中心', icon: '🎟️', color: '#FF6B35', bg: '#FFE5D9' },
  { label: '新品首发', icon: '✨', color: '#06A77D', bg: '#DDF3EC' },
  { label: '潮流榜单', icon: '🏆', color: '#FFB627', bg: '#FFF1D6' },
  { label: '橙心超市', icon: '🛒', color: '#4361EE', bg: '#E2E7FD' },
  { label: '会员专区', icon: '👑', color: '#9D4EDD', bg: '#F0E5FB' },
  { label: '直播好物', icon: '📺', color: '#FF6B35', bg: '#FFE5D9' }
]

这里定义了三个核心配置数据结构。STATUS_MAPCATEGORY_MAP 使用了 Record<string, T> 类型,这是 HarmonyOS ArkTS API 24 支持的索引签名类型。通过商品状态字符串(如"在售"“闪购”)或分类字符串(如"数码"“服饰”)作为键,可以 O(1) 时间复杂度获取完整的渲染元信息,包括显示文字、前景色、背景色和图标。这种设计避免了在 UI 模板中使用冗长的 if-else 或 switch 分支,极大简化了渲染逻辑。

背景色统一使用 rgba 半透明格式,这是经过精心设计的——半透明背景色叠加在白色卡片背景上时,会呈现出柔和的色调,避免纯色背景的刺眼感。KINGKONG_ENTRIES 是首页金刚区(功能入口宫格)的配置数组,包含 8 个入口。每个入口有独立的主题色和背景色,在视觉上形成彩虹般的色阶排列。值得注意的是金刚区入口的颜色与分类配色有意识地保持差异化——例如"会员专区"使用紫色 #9D4EDD,"橙心超市"使用蓝色 #4361EE,这些颜色不在 CATEGORY_MAP 中出现,使得功能入口与商品分类在视觉上形成区分。Record 类型在 ArkTS 编译期会生成类型安全的查找代码,访问不存在的键时返回 undefined,但在这个应用中所有键都是预定义的,不存在运行时查找失败的风险。


在这里插入图片描述

代码段6:Banner轮播与优惠券配置

const BANNERS: BannerInfo[] = [
  { title: '潮流数码节', sub: '爆款直降300元', tag: '数码专场', price: '1299', bg: '#FF6B35', icon: '🎧' },
  { title: '秋冬新风尚', sub: '大牌服饰5折起', tag: '服饰专场', price: '199', bg: '#E85D04', icon: '🧥' },
  { title: '美妆焕新季', sub: '买1享3超值套装', tag: '美妆专场', price: '99', bg: '#E63946', icon: '💄' }
]

const COUPONS: CouponMeta[] = [
  { title: '全品类通用券', desc: '全场商品可用,不可叠加', value: '50', color: '#FF6B35', bg: '#FFE5D9', expireDate: '2026.09.30' },
  { title: '数码专项券', desc: '数码频道专享满减', value: '100', color: '#E85D04', bg: '#FFEDE4', expireDate: '2026.09.15' },
  { title: '美妆焕新券', desc: '美妆频道专享满减', value: '30', color: '#E63946', bg: '#FDE3E5', expireDate: '2026.08.31' },
  { title: '新人专享券', desc: '仅限新注册用户领取', value: '20', color: '#06A77D', bg: '#DDF3EC', expireDate: '2026.12.31' },
  { title: '会员至尊券', desc: '黄金会员专享大额券', value: '200', color: '#FFB627', bg: '#FFF1D6', expireDate: '2026.10.20' }
]

在这里插入图片描述

BANNERS 数组定义了首页顶部轮播图的三张 Banner,每张包含标题、副标题、标签、起价、背景色和图标。背景色使用了不同的橙红色调,与主品牌色系保持一致但又有层次区分。在 FeaturedContent 组件中,这些 Banner 通过 Swiper 组件实现自动轮播,每张 Banner 的布局采用左文右图的结构——左侧是标签、标题、副标题和起价信息,右侧是 56px 的大号 Emoji 图标,形成视觉焦点。

COUPONS 数组定义了领券中心的五张优惠券。每张优惠券有独立的主题色(color)和浅色背景(bg),在 UI 渲染时优惠券的金额数字使用 color 着色,卡片边框使用 bg 着色,形成色彩呼应。value 字段以字符串类型存储面额,这看似不符合数值处理的直觉,但实际上在 UI 显示时优惠券金额通常需要直接拼接货币符号和文字(如"¥50"),字符串类型避免了额外的类型转换。expireDate 字段使用点分日期格式(如"2026.09.30"),在券卡片底部以 9px 小字展示有效期。parseInt(coupon.value) * 4 的计算出现在优惠券列表渲染中,用于动态计算满减门槛(如面额 50 元的券需要满 200 元可用),这种逻辑将业务规则与数据配置分离,便于后续调整。


代码段7:品牌与时间轴配置

const BRANDS: BrandMeta[] = [
  { name: '橙意数码', slogan: '年轻人数码首选', icon: '🍊', color: '#FF6B35', bg: '#FFE5D9', fans: '286万' },
  { name: '潮流前线', slogan: '潮流服饰风向标', icon: '👕', color: '#E85D04', bg: '#FFEDE4', fans: '532万' },
  { name: '花颜美妆', slogan: '国货美妆之光', icon: '🌸', color: '#E63946', bg: '#FDE3E5', fans: '198万' },
  { name: '橙居生活', slogan: '家居好物集合店', icon: '🛋️', color: '#06A77D', bg: '#DDF3EC', fans: '124万' },
  { name: '跃动体育', slogan: '专业运动装备', icon: '🏀', color: '#4361EE', bg: '#E2E7FD', fans: '167万' },
  { name: '零食大亨', slogan: '全球零食搬运工', icon: '🍪', color: '#FFB627', bg: '#FFF1D6', fans: '403万' }
]

const SPEC_OPTIONS: SpecOption[] = [
  { label: '官方标配', color: '#FF6B35', bg: '#FFE5D9' },
  { label: '豪华套装', color: '#FF6B35', bg: '#FFE5D9' },
  { label: '旗舰顶配', color: '#FF6B35', bg: '#FFE5D9' }
]

const TIMELINE_ITEMS: TimeLineItem[] = [
  { time: '08-23 10:00', title: '超感知降噪耳机 Pro3', desc: '预约立减100,前100名送保护套', icon: '🎧', color: '#FF6B35' },
  { time: '08-23 14:00', title: '轻氧焕肤精华礼盒', desc: '新品首发买1享3,限量5000套', icon: '🧴', color: '#E63946' },
  { time: '08-24 09:00', title: '城市通勤折叠单车', desc: '首发价直降400,晒单返20元', icon: '🚲', color: '#06A77D' },
  { time: '08-25 20:00', title: '智能扫地机器人 X9', desc: '激光导航,预约赠耗材大礼包', icon: '🤖', color: '#4361EE' },
  { time: '08-26 12:00', title: '手工ins风陶瓷餐盘', desc: '设计师联名款,限量发售', icon: '🍽️', color: '#FFB627' }
]

const RANK_MEDALS: RankMedal[] = [
  { label: '1', color: '#FFFFFF', bg: '#FFB627' },
  { label: '2', color: '#FFFFFF', bg: '#FF6B35' },
  { label: '3', color: '#FFFFFF', bg: '#E85D04' }
]

const SHOP_TAB_LABELS: string[] = ['精选', '闪购', '品牌', '券', '新品', '排行']

const BOTTOM_TAB_META: KingKongEntry[] = [
  { label: '首页', icon: '🏠', color: '#FF6B35', bg: '#FFF8F3' },
  { label: '分类', icon: '🗂️', color: '#FF6B35', bg: '#FFF8F3' },
  { label: '购物车', icon: '🛒', color: '#FF6B35', bg: '#FFF8F3' },
  { label: '我的', icon: '👤', color: '#FF6B35', bg: '#FFF8F3' }
]

在这里插入图片描述

这一段集中定义了品牌专区、规格选项、新品时间轴、排行榜奖牌以及两组 Tab 配置。BRANDS 数组为品牌 Tab 提供数据源,每个品牌有专属图标、口号、主题色和粉丝数。在 BrandContent 组件中,品牌以宫格形式排列,选中品牌高亮显示其主题色背景,下方列表通过 brandProducts(this.selectedBrand) 函数动态过滤该品牌下的商品。

SPEC_OPTIONS 定义了加入购物车弹框中的三个规格选项,虽然三者颜色相同(均使用主色橙),但可以通过修改配置轻松实现差异化。TIMELINE_ITEMS 是新品首发页面的时间轴数据,每条包含具体开售时间、商品名称、优惠描述和主题色。在 NewContent 组件中,这些条目通过 ForEach 渲染为垂直时间轴,圆点颜色取自 item.color,连接线使用 border 色绘制。RANK_MEDALS 仅定义了前三名的奖牌样式,金色、橙色、深橙的渐变呼应了领奖台的传统配色。SHOP_TAB_LABELSBOTTOM_TAB_META 分别定义了顶部 6 功能 Tab 和底部 4 导航 Tab 的标签文字和图标,底部 Tab 的 colorbg 字段统一使用主色系,保持品牌一致性。


代码段8:静态商品数据集

const PRODUCT_LIST: ProductItem[] = [
  new ProductItem(1, '超感知降噪耳机 Pro3', '数码', '🎧', 1299, 1599, 23850, 326, 4.9, 18234, '闪购',
    ['爆款', '降噪', '40h续航'], '旗舰级主动降噪,Hi-Res双认证音质,单次充电畅听40小时', '橙意数码', '橙意数码旗舰店', true, '#FFE5D9'),
  new ProductItem(2, '轻氧焕肤精华礼盒', '美妆', '🧴', 199, 399, 45210, 880, 4.8, 30215, '在售',
    ['买1享3', '国货', '敏感肌可用'], '烟酰胺+玻色因双效配方,28天焕亮肤色,礼盒装送人自用两相宜', '花颜美妆', '花颜官方自营店', true, '#FDE3E5'),
  new ProductItem(3, '城市通勤折叠单车', '运动', '🚲', 1599, 2199, 8930, 45, 4.7, 6210, '在售',
    ['轻量', '快拆', '25km续航'], '航空铝车架仅12.8kg,三秒折叠,通勤地铁无缝衔接', '跃动体育', '跃动体育专卖店', false, '#E2E7FD'),
  new ProductItem(4, '智能扫地机器人 X9', '家居', '🤖', 2899, 3499, 15620, 120, 4.9, 11890, '在售',
    ['激光导航', '自动集尘', '拖扫一体'], 'LDS激光导航+AI避障,5200Pa大吸力,基站自动集尘清洗', '橙居生活', '橙居生活旗舰店', true, '#DDF3EC'),
  new ProductItem(5, 'oversize落日卫衣', '服饰', '👕', 139, 259, 62300, 560, 4.6, 45120, '闪购',
    ['潮流', '加绒', '三色可选'], '450g重磅纯棉,落日渐变印花,oversize慵懒版型', '潮流前线', '潮流前线旗舰店', true, '#FFEDE4'),
  new ProductItem(6, '手冲咖啡大师套装', '家居', '☕', 269, 429, 9870, 210, 4.8, 7643, '在售',
    ['入门优选', '全套', '礼盒装'], '含手冲壶+滤杯+磨豆机+分享壶,新手一键进阶手冲大师', '橙居生活', '橙居生活好物店', true, '#FFF1D6'),
  new ProductItem(7, '全屋香薰蜡烛礼盒', '家居', '🕯️', 99, 169, 31500, 0, 4.7, 22310, '缺货',
    ['大豆蜡', '4味套装', '治愈系'], '四种治愈香型,天然大豆蜡48小时持久香氛', '橙居生活', '橙居生活选品店', true, '#FFF1D6'),
  // ... 更多商品
]

PRODUCT_LIST 是应用的核心商品数据源,包含 15 个 ProductItem 实例。每个商品通过构造函数注入全部 16 个参数,确保数据完整性。这些商品覆盖了数码、美妆、运动、家居、服饰、食品六个分类,每个分类有 2-3 个商品,为分类页和品牌页提供了丰富的展示素材。

数据设计上有几个值得关注的细节。第一,商品价格区间从 69 元到 2899 元,覆盖了低中高三个价位段,使得闪购折扣计算和价格展示具有多样性。第二,status 字段包含"在售"“预售”“缺货”"闪购"四种状态,其中第 7 号商品"全屋香薰蜡烛礼盒"被设为"缺货"状态且 stock 为 0,用于演示缺货状态的 UI 渲染。第三,salesCount 字段的数值经过精心设计,从 8930 到 88200 不等,为排行榜柱状图提供了有意义的视觉差异。第四,每个商品的 coverColor 与其分类的主题色保持一致,如数码类商品使用 #FFE5D9,美妆类使用 #FDE3E5,这确保了商品卡片在列表中的色彩与分类标识的统一性。第五,tags 数组通常包含 3 个标签,这些标签在详情弹框中渲染为橙色标签胶囊,在闪购页面中取第一个标签作为简短卖点展示。


代码段9:购物车初始数据与克隆函数

const INIT_CART: CartItem[] = [
  new CartItem(1, '超感知降噪耳机 Pro3', '🎧', 1299, 1, '官方标配', '橙意数码旗舰店', true, '#FFE5D9'),
  new CartItem(2, '轻氧焕肤精华礼盒', '🧴', 199, 2, '买1享3礼盒', '花颜官方自营店', true, '#FDE3E5'),
  new CartItem(3, 'oversize落日卫衣', '👕', 139, 1, 'L / 落日橘', '潮流前线旗舰店', false, '#FFEDE4'),
  new CartItem(4, '每日坚果科学配比装', '🥜', 89, 3, '30包/箱', '零食大亨自营店', true, '#FFF1D6'),
  new CartItem(5, '电竞机械键盘 84键', '⌨️', 329, 1, '白灰轴 / 三模', '橙意数码装备铺', false, '#FFE5D9'),
  new CartItem(6, '智能扫地机器人 X9', '🤖', 2899, 1, '白 / 基站版', '橙居生活旗舰店', true, '#DDF3EC'),
  new CartItem(7, '便携折叠电风扇', '🌀', 69, 2, '奶油白', '橙意小电器店', false, '#FFE5D9'),
  new CartItem(8, '碎花法式茶歇裙', '👗', 229, 1, 'M / 碎花蓝', '潮流女装甄选店', true, '#FFEDE4')
]

function cloneCart(): CartItem[] {
  const list: CartItem[] = []
  for (let i = 0; i < INIT_CART.length; i++) {
    const c: CartItem = INIT_CART[i]
    list.push(new CartItem(c.id, c.productName, c.icon, c.price, c.quantity,
      c.spec, c.shop, c.selected, c.coverColor))
  }
  return list
}

在这里插入图片描述

INIT_CART 定义了 8 个初始购物车项,作为应用启动时的默认购物车数据。其中部分商品的 selected 为 true,部分为 false,这种设计使得购物车页面在首次展示时即可呈现"部分选中"的状态,方便用户理解全选/取消全选的交互逻辑。spec 字段展示了真实电商场景中的规格信息多样性——既有简单的"官方标配",也有复合的"L / 落日橘"(尺码+颜色)和"白灰轴 / 三模"(轴体+连接方式)。

cloneCart() 函数是一个深拷贝工具,它遍历 INIT_CART 数组,为每个元素创建一个新的 CartItem 实例。这个函数存在的意义在于:HarmonyOS ArkTS 中 @State 修饰的数组是引用类型,如果直接将 INIT_CART 赋值给 @State cartItems,那么对购物车的修改会反向污染原始常量数据。通过 cloneCart() 创建一份独立副本,确保了应用每次启动时购物车数据都从干净状态开始。这种深拷贝策略在 HarmonyOS ArkTS API 24 中是处理可变状态初始化的标准模式。使用 for 循环而非 map 或展开运算符(spread operator)是 ArkTS 的刻意选择——ArkTS 对函数式 API 的支持有限,传统 for 循环在编译优化和类型推导方面更加可靠。


代码段10:纯函数工具集

function formatPrice(value: number): string {
  return value.toFixed(2)
}

function discountPercent(price: number, original: number): number {
  if (original <= 0) {
    return 100
  }
  return Math.round(price / original * 100) / 10
}

function formatSales(count: number): string {
  if (count >= 10000) {
    return (count / 10000).toFixed(1) + '万'
  }
  return count.toString()
}

这三个纯函数是应用中最高频调用的工具函数,它们分别处理价格格式化、折扣计算和销量格式化。formatPrice 使用 Number.toFixed(2) 将数值统一格式化为两位小数,确保所有价格显示的一致性。在 ArkTS 中,toFixed 返回字符串,无需额外类型转换。

discountPercent 计算折扣比例,逻辑是 price / original * 100 / 10,即先计算百分比再除以 10 得到折扣值(如 7 折、8.5 折)。Math.round 用于消除浮点数精度问题。边界条件 original <= 0 返回 100(即原价),防止除零错误。formatSales 实现了中国电商场景的"万"级数字格式化:当销量超过 10000 时,除以 10000 并保留一位小数,追加"万"字后缀。例如 23850 会显示为"2.4万",88200 显示为"8.8万"。这种格式化策略在视觉上更加紧凑,避免了长串数字对 UI 布局的挤压。三个函数都是无副作用的纯函数,不依赖任何外部状态,可以在任何组件中安全调用。在 ArkTS API 24 中,顶层函数(非组件方法)的调用没有额外开销,编译器会进行内联优化。


代码段11:粒子特效系统

const PARTICLE_ICONS: string[] = ['🎟️', '🛍️', '🏷️', '💰', '🎁', '🧧']

function buildParticles(): ParticleDot[] {
  const dots: ParticleDot[] = []
  for (let i = 0; i < 16; i++) {
    dots.push({
      x: 6 + (i * 5.8) % 88,
      y: 10 + (i * 37) % 80,
      size: 14 + (i % 4) * 6,
      color: i % 2 === 0 ? '#FF6B35' : '#FFB627',
      speed: 0.4 + (i % 5) * 0.15,
      phase: i * 0.7,
      icon: PARTICLE_ICONS[i % PARTICLE_ICONS.length]
    })
  }
  return dots
}

function advanceParticles(dots: ParticleDot[]): ParticleDot[] {
  const next: ParticleDot[] = []
  for (let i = 0; i < dots.length; i++) {
    const d: ParticleDot = dots[i]
    const y: number = d.y - d.speed
    next.push({
      x: d.x + Math.sin(d.phase + y * 0.12) * 0.6,
      y: y < -8 ? 102 : y,
      size: d.size,
      color: d.color,
      speed: d.speed,
      phase: d.phase,
      icon: d.icon
    })
  }
  return next
}

在这里插入图片描述

粒子特效系统是本应用最具视觉特色的模块。buildParticles() 初始化 16 个粒子,每个粒子使用基于索引 i 的确定性公式生成位置和运动参数。x 坐标通过 (i * 5.8) % 88 生成横向分布,取模运算确保坐标在 6-94 的百分比范围内。y 坐标使用 (i * 37) % 80 生成纵向分布,乘数 37 是一个质数,确保 16 个粒子的 y 值分布均匀。size 使用 14 + (i % 4) * 6 在 14-32px 之间循环。speed 范围为 0.4-1.0,通过 (i % 5) * 0.15 生成 5 级速度差异。icon 从 6 个购物相关 Emoji 中循环选取。

advanceParticles() 是粒子动画的核心推进函数。它接收当前帧的粒子数组,返回下一帧的新数组。每个粒子的 y 坐标减去 speed 实现向上飘浮。x 坐标通过 Math.sin(d.phase + y * 0.12) * 0.6 添加正弦摆动,模拟粒子在空气中飘动的自然轨迹。当粒子飞出顶部(y < -8)时,y 被重置为 102(即底部以下),实现循环复用。这种"回收-重置"模式避免了频繁创建和销毁粒子对象的开销。整个粒子系统通过 setInterval 每 60ms 调用一次 advanceParticles,并将结果赋值给 @State particles,触发 ForEach 重新渲染。16 个粒子的渲染负载在 HarmonyOS 设备上完全可以接受,不会造成帧率下降。


代码段12:Tab枚举与数据过滤辅助函数

enum ShopTab {
  FEATURED = 0,
  FLASH = 1,
  BRAND = 2,
  COUPON = 3,
  NEW = 4,
  RANK = 5
}

enum BottomTab {
  HOME = 0,
  CATEGORY = 1,
  CART = 2,
  MINE = 3
}

function cloneCart(): CartItem[] {
  const list: CartItem[] = []
  for (let i = 0; i < INIT_CART.length; i++) {
    const c: CartItem = INIT_CART[i]
    list.push(new CartItem(c.id, c.productName, c.icon, c.price, c.quantity,
      c.spec, c.shop, c.selected, c.coverColor))
  }
  return list
}

function sortedBySales(): ProductItem[] {
  const list: ProductItem[] = PRODUCT_LIST.slice()
  list.sort((a: ProductItem, b: ProductItem) => b.salesCount - a.salesCount)
  return list
}

function pad2(value: number): string {
  return value < 10 ? '0' + value.toString() : value.toString()
}

function selectedSpecLabel(index: number): string {
  if (index >= 0 && index < SPEC_OPTIONS.length) {
    return SPEC_OPTIONS[index].label
  }
  return SPEC_OPTIONS[0].label
}

function flashProducts(): ProductItem[] {
  return PRODUCT_LIST.filter((p: ProductItem) => p.status === '闪购' || p.originalPrice / p.price >= 1.8)
}

function brandProducts(brand: string): ProductItem[] {
  return PRODUCT_LIST.filter((p: ProductItem) => p.brand === brand)
}

function topBrandNames(): string[] {
  const names: string[] = []
  for (let i = 0; i < BRANDS.length; i++) {
    names.push(BRANDS[i].name)
  }
  return names
}

ShopTabBottomTab 两个枚举定义了 Tab 的索引常量。使用枚举而非魔法数字(如直接用 0、1、2)使得代码可读性大幅提升——currentBottomTab === BottomTab.CARTcurrentBottomTab === 2 更加自解释。在 ArkTS API 24 中,enum 会被编译为数字常量,运行时无额外开销。

辅助函数集提供了数据过滤和格式化的核心能力。sortedBySales() 通过 slice() 创建 PRODUCT_LIST 的浅拷贝,再使用 sort() 按销量降序排列。slice() 的调用至关重要——sort() 会修改原数组,如果不先拷贝,PRODUCT_LIST 的顺序将被永久改变,影响其他页面的渲染。flashProducts() 使用 filter 筛选闪购商品,筛选条件是 status === '闪购' 或折扣力度大于等于 1.8 倍(即 originalPrice / price >= 1.8),这种双条件设计确保了即使非"闪购"状态但折扣力度大的商品也会出现在闪购页面。brandProducts() 接受品牌名参数返回该品牌下的商品列表,用于品牌 Tab 的动态列表渲染。pad2() 是时间格式化函数,将个位数补零(如 5 变为"05"),用于闪购倒计时的显示。selectedSpecLabel() 通过索引安全获取规格名称,包含边界检查和默认值返回。


三、组件生命周期与数据流

弹框系统 闪购倒计时 粒子系统 定时器 MarketPage 用户操作 弹框系统 闪购倒计时 粒子系统 定时器 MarketPage 用户操作 aboutToAppear 生命周期 aboutToDisappear 生命周期 setInterval(60ms) 粒子动画 setInterval(1000ms) 闪购倒计时 advanceParticles() @State particles 更新 ForEach 重渲染粒子层 flashSeconds - 1 @State flashSeconds 更新 flashHour/Minute/Second 重算 点击商品"+" selectedProduct = product showCartModal = true 渲染 addToCartModal 选择规格/数量 selectedSpec/cartQuantity 更新 点击"确定" addToCart(product) cartItems 更新 showCartModal = false 底部Tab购物车角标刷新 clearInterval(particleTimer) clearInterval(flashTimer)

上图展示了从应用启动到销毁的完整数据流时序。aboutToAppear 中启动两个定时器,分别驱动粒子动画和闪购倒计时。用户交互(如点击加购按钮)触发状态变更,引发弹框渲染和数据更新。aboutToDisappear 中清理定时器,防止内存泄漏。


代码段13:主页面状态声明

@Entry
@Component
struct MarketPage {
  @State currentBottomTab: number = BottomTab.HOME
  @State currentShopTab: number = ShopTab.FEATURED
  @State cartItems: CartItem[] = cloneCart()
  @State showCartModal: boolean = false
  @State showAddressModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedProduct: ProductItem = PRODUCT_LIST[0]
  @State deleteTargetId: number = 0
  @State selectedSpec: number = 0
  @State cartQuantity: number = 1
  @State searchKey: string = ''
  @State messageCount: number = 6
  @State flashSeconds: number = 4 * 3600 + 25 * 60 + 18
  @State detailSlideIndex: number = 0
  @State particles: ParticleDot[] = buildParticles()
  @State address: AddressInfo = {
    name: '陈橙子',
    phone: '138****6688',
    region: '广东省 深圳市 南山区',
    detail: '科技园南区深南大道9988号潮流大厦A座2201室',
    tag: '公司'
  }
  @State addrName: string = '陈橙子'
  @State addrPhone: string = '138****6688'
  @State addrRegion: string = '广东省 深圳市 南山区'
  @State addrDetail: string = '科技园南区深南大道9988号潮流大厦A座2201室'
  @State addrTag: string = '公司'
  @State couponTaken: boolean[] = [false, false, false, false, false]
  particleTimer: number = -1
  flashTimer: number = -1
  rankList: ProductItem[] = sortedBySales()

MarketPage 是整个应用的入口组件,使用 @Entry@Component 双重装饰。@Entry 标记该组件为页面根节点,@Component 声明这是一个可复用的声明式组件。组件内部通过 @State 声明了 22 个响应式状态变量,覆盖了应用运行所需的全部可变数据。

状态变量可分为四组。第一组是路由控制状态:currentBottomTab 控制底部四 Tab 切换,currentShopTab 控制首页顶部六功能 Tab 切换。第二组是弹框控制状态:四个布尔变量(showCartModalshowAddressModalshowDeleteModalshowDetailModal)分别控制四种弹框的显隐,通过互斥条件确保同一时刻只有一个弹框可见。第三组是业务数据状态:cartItems 持有购物车数据(通过 cloneCart() 初始化为独立副本),selectedProduct 持有当前选中商品,address 持有收货地址,couponTaken 以布尔数组记录优惠券领取状态。第四组是临时交互状态:selectedSpeccartQuantity 在加入购物车弹框中使用,deleteTargetId 在删除确认弹框中使用,flashSeconds 存储闪购倒计时秒数,particles 存储粒子数组。

值得注意的设计细节是地址编辑的双缓冲机制。address 是正式地址数据,而 addrNameaddrPhoneaddrRegionaddrDetailaddrTag 是编辑过程中的临时缓冲。当用户打开地址编辑弹框时,正式数据被复制到缓冲变量;用户在弹框中修改的是缓冲变量;点击"保存地址"时,缓冲变量的值才被提交到 address。这种模式确保了用户取消编辑时不会污染正式数据。另外,particleTimerflashTimer 不使用 @State 修饰——它们是非响应式的定时器 ID,不需要触发 UI 重渲染。rankList 也未使用 @State,因为排行榜数据在应用生命周期内不会变化。


代码段14:组件生命周期方法

aboutToAppear(): void {
  this.particleTimer = setInterval(() => {
    this.particles = advanceParticles(this.particles)
  }, 60)
  this.flashTimer = setInterval(() => {
    if (this.flashSeconds > 0) {
      this.flashSeconds = this.flashSeconds - 1
    } else {
      this.flashSeconds = 6 * 3600
    }
  }, 1000)
}

aboutToDisappear(): void {
  if (this.particleTimer >= 0) {
    clearInterval(this.particleTimer)
  }
  if (this.flashTimer >= 0) {
    clearInterval(this.flashTimer)
  }
}

aboutToAppearaboutToDisappear 是 HarmonyOS ArkTS API 24 组件生命周期的两个关键回调。aboutToAppear 在组件创建后、UI 渲染前被调用,适合执行定时器启动、数据初始化等操作。aboutToDisappear 在组件销毁前被调用,用于资源清理。

aboutToAppear 中启动了两个定时器。粒子定时器以 60ms 间隔运行(约 16.7 FPS),每次调用 advanceParticles 计算下一帧粒子位置并赋值给 @State particles,触发粒子层的 ForEach 重渲染。60ms 的间隔是经过权衡的选择——更短的间隔(如 16ms)会增加渲染负担但动画更流畅,更长的间隔(如 100ms)则动画不够连贯。闪购定时器以 1000ms 间隔运行,每次将 flashSeconds 减 1,当倒计时归零时重置为 6 * 3600(即 6 小时),实现循环倒计时效果。aboutToDisappear 中的清理逻辑使用 >= 0 判断而非 != -1,这是防御性编程的体现——即使定时器 ID 因某种原因为非负非 -1 的值,清理逻辑也能正确执行。定时器清理是 ArkTS 开发中不可忽视的内存管理环节——如果组件销毁后定时器仍在运行,回调函数中的 this 引用会阻止垃圾回收,导致内存泄漏。


代码段15:购物车计算方法

cartCount(): number {
  let total: number = 0
  for (let i = 0; i < this.cartItems.length; i++) {
    total += this.cartItems[i].quantity
  }
  return total
}

cartSelectedCount(): number {
  let total: number = 0
  for (let i = 0; i < this.cartItems.length; i++) {
    if (this.cartItems[i].selected) {
      total += this.cartItems[i].quantity
    }
  }
  return total
}

cartSelectedTotal(): number {
  let total: number = 0
  for (let i = 0; i < this.cartItems.length; i++) {
    if (this.cartItems[i].selected) {
      total += this.cartItems[i].quantity * this.cartItems[i].price
    }
  }
  return total
}

isAllSelected(): boolean {
  if (this.cartItems.length === 0) {
    return false
  }
  for (let i = 0; i < this.cartItems.length; i++) {
    if (!this.cartItems[i].selected) {
      return false
    }
  }
  return true
}

这四个计算方法是购物车业务逻辑的核心。cartCount() 计算购物车中所有商品的总件数(非商品种类数),用于顶部购物车图标的角标显示和"我的"页面的购物车数量展示。cartSelectedCount() 仅计算已选中商品的件数,用于底部结算栏的"去结算(N)"按钮文字。cartSelectedTotal() 计算已选中商品的总金额,用于结算栏的合计金额展示。isAllSelected() 判断是否所有商品都被选中,用于全选勾选框的状态同步。

这些方法都使用 for 循环而非 reducefilter 等 Array 原型方法。这是 ArkTS 的有意选择——在 ArkTS API 24 中,for 循环的编译产物更加高效,且类型推导更可靠。每个方法都遍历整个 cartItems 数组,时间复杂度为 O(n)。对于购物车这种 n 通常不超过几十的场景,性能完全可接受。isAllSelected() 的实现采用了短路优化——遇到第一个未选中的商品立即返回 false,无需遍历整个数组。空数组边界条件返回 false,符合"没有商品时全选框不可选"的交互预期。这些计算方法在 UI 模板中被直接调用(如 this.cartCount().toString()),由于 @State cartItems 的变化会触发组件重渲染,方法返回值会自动更新到 UI 上。


代码段16:购物车操作方法

toggleSelectAll(): void {
  const target: boolean = !this.isAllSelected()
  for (let i = 0; i < this.cartItems.length; i++) {
    this.cartItems[i].selected = target
  }
  this.cartItems = this.cartItems.slice()
}

toggleSelectItem(id: number): void {
  for (let i = 0; i < this.cartItems.length; i++) {
    if (this.cartItems[i].id === id) {
      this.cartItems[i].selected = !this.cartItems[i].selected
    }
  }
  this.cartItems = this.cartItems.slice()
}

changeQuantity(id: number, delta: number): void {
  for (let i = 0; i < this.cartItems.length; i++) {
    if (this.cartItems[i].id === id) {
      const next: number = this.cartItems[i].quantity + delta
      if (next >= 1) {
        this.cartItems[i].quantity = next
      }
    }
  }
  this.cartItems = this.cartItems.slice()
}

confirmDelete(): void {
  const remain: CartItem[] = []
  for (let i = 0; i < this.cartItems.length; i++) {
    if (this.cartItems[i].id !== this.deleteTargetId) {
      remain.push(this.cartItems[i])
    }
  }
  this.cartItems = remain
  this.showDeleteModal = false
}

addToCart(product: ProductItem): void {
  const exist: CartItem[] = this.cartItems.filter((c: CartItem) => c.id === product.id)
  if (exist.length > 0) {
    for (let i = 0; i < this.cartItems.length; i++) {
      if (this.cartItems[i].id === product.id) {
        this.cartItems[i].quantity += this.cartQuantity
        this.cartItems[i].selected = true
      }
    }
  } else {
    const specName: string = selectedSpecLabel(this.selectedSpec)
    this.cartItems.push(new CartItem(product.id, product.name, product.icon,
      product.price, this.cartQuantity, specName, product.shop, true, product.coverColor))
  }
  this.cartItems = this.cartItems.slice()
  this.showCartModal = false
  this.cartQuantity = 1
  this.selectedSpec = 0
}

这五个方法是购物车的全部写操作逻辑。toggleSelectAll() 通过取反 isAllSelected() 的结果来决定目标状态,然后统一设置所有购物车项的 selected 字段。toggleSelectItem() 根据 id 查找目标项并切换其选中状态。changeQuantity() 接受增量参数 delta(+1 或 -1),通过 next >= 1 的边界检查确保数量不会降到 0 以下。confirmDelete() 通过遍历过滤的方式移除目标商品,同时关闭删除确认弹框。addToCart() 是最复杂的方法——首先通过 filter 检查商品是否已在购物车中,如果已存在则增加数量并设为选中,如果不存在则创建新的 CartItempush 进数组。

所有方法末尾都有一个关键操作:this.cartItems = this.cartItems.slice()。这是 ArkTS 状态更新的核心技巧。在 HarmonyOS ArkTS API 24 中,@State 修饰的数组对元素属性的修改不会自动触发 UI 更新——因为数组的引用地址没有变化。通过 slice() 创建一个新数组(浅拷贝)并重新赋值,@State 检测到引用变化后触发重渲染。addToCart() 方法在操作完成后重置了 cartQuantityselectedSpec,确保下次打开弹框时这些临时状态回到初始值,避免残留状态影响用户体验。


代码段17:闪购倒计时与柱状图方法

closeAllModals(): void {
  this.showCartModal = false
  this.showAddressModal = false
  this.showDeleteModal = false
  this.showDetailModal = false
}

flashHour(): string {
  return pad2(Math.floor(this.flashSeconds / 3600))
}

flashMinute(): string {
  return pad2(Math.floor((this.flashSeconds % 3600) / 60))
}

flashSecond(): string {
  return pad2(this.flashSeconds % 60)
}

maxRankSales(): number {
  return this.rankList.length > 0 ? this.rankList[0].salesCount : 1
}

barHeight(sales: number): number {
  return Math.max(8, Math.round(sales / this.maxRankSales() * 130))
}

closeAllModals() 是一个便捷方法,一次性关闭所有弹框。它被绑定到弹框遮罩层的 onClick 事件上——用户点击遮罩区域的任意位置即可关闭当前弹框。

flashHour()flashMinute()flashSecond() 三个方法将 flashSeconds 总秒数拆解为时、分、秒的显示字符串。Math.floor(this.flashSeconds / 3600) 计算小时数,(this.flashSeconds % 3600) / 60 计算去除小时后的分钟数,this.flashSeconds % 60 计算秒数。每个结果都经过 pad2() 补零处理,确保显示为两位数格式(如"04:25:18")。由于 flashSeconds@State 变量,且每秒由定时器更新一次,这三个方法的返回值也会每秒刷新,驱动闪购倒计时 UI 的实时变化。

maxRankSales() 返回排行榜中最高销量值(由于 rankList 已按销量降序排列,首元素即为最高值),用于柱状图的高度归一化。barHeight() 接受某商品的销量,计算其在柱状图中的像素高度:sales / maxSales * 130 将销量映射到 0-130 像素范围,Math.max(8, ...) 确保最小高度为 8px,避免销量极低的商品柱体完全不可见。Math.round 取整确保像素值为整数,避免亚像素渲染模糊。这种归一化映射策略是数据可视化的基础模式。


三点五、购物车数据流图

下图展示了购物车核心操作的状态流转路径,从用户交互触发到 UI 更新的完整链路。

UI刷新

数据更新

状态变更

用户交互

点击商品+按钮

点击勾选框

点击加减按钮

点击删除按钮

点击全选按钮

点击确定加购

selectedProduct = product

showCartModal = true

toggleSelectItem id

changeQuantity id delta

deleteTargetId = id

showDeleteModal = true

toggleSelectAll

addToCart product

cartItems i.selected 取反

cartItems i.quantity += delta

cartItems 过滤移除

cartItems 全部 selected 统一

已有商品 quantity += cartQuantity

新商品 push 到 cartItems

cartItems = cartItems.slice

showModal = false

cartCount 角标更新

结算栏合计刷新

全选框状态同步

上图清晰呈现了购物车五大操作的并行与串行关系。所有数据更新操作最终汇聚到 cartItems = cartItems.slice() 这一行代码——这是触发 ArkTS 框架检测状态变更并重渲染 UI 的关键节点。加购操作存在分支判断(已有商品走数量累加路径,新商品走 push 路径),两条路径最终都汇入同一个 slice 赋值节点。


代码段18:页面骨架与路由分发

build() {
  Stack() {
    Column() {
      if (this.currentBottomTab === BottomTab.HOME) {
        Column() {
          this.homeHeader()
          this.shopTabBar()
          if (this.currentShopTab === ShopTab.FEATURED) {
            FeaturedContent({
              onAddCart: (product: ProductItem) => {
                this.selectedProduct = product
                this.showCartModal = true
              },
              onOpenDetail: (product: ProductItem) => {
                this.selectedProduct = product
                this.detailSlideIndex = 0
                this.showDetailModal = true
              }
            })
          } else if (this.currentShopTab === ShopTab.FLASH) {
            FlashContent({
              flashHourText: this.flashHour(),
              flashMinuteText: this.flashMinute(),
              flashSecondText: this.flashSecond(),
              onAddCart: (product: ProductItem) => {
                this.selectedProduct = product
                this.showCartModal = true
              },
              onOpenDetail: (product: ProductItem) => {
                this.selectedProduct = product
                this.detailSlideIndex = 0
                this.showDetailModal = true
              }
            })
          } else if (this.currentShopTab === ShopTab.COUPON) {
            CouponContent({
              takenFlags: this.couponTaken,
              onTakeCoupon: (index: number) => {
                const flags: boolean[] = this.couponTaken.slice()
                flags[index] = true
                this.couponTaken = flags
              }
            })
          }
          // ... BRAND, NEW, RANK 分支
        }
        .width('100%')
        .layoutWeight(1)
      } else if (this.currentBottomTab === BottomTab.CART) {
        CartContent({
          items: this.cartItems,
          allSelected: this.isAllSelected(),
          selectedCount: this.cartSelectedCount(),
          selectedTotal: this.cartSelectedTotal(),
          onToggleItem: (id: number) => { this.toggleSelectItem(id) },
          onToggleAll: () => { this.toggleSelectAll() },
          onQuantity: (id: number, delta: number) => { this.changeQuantity(id, delta) },
          onDelete: (id: number) => {
            this.deleteTargetId = id
            this.showDeleteModal = true
          },
          onGoHome: () => { this.currentBottomTab = BottomTab.HOME }
        })
      }
      this.bottomTabBar()
    }
    .width('100%')
    .height('100%')

    this.particleLayer()

    if (this.showCartModal || this.showAddressModal || this.showDeleteModal || this.showDetailModal) {
      this.modalOverlay()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build() 方法是 ArkTS 组件的核心渲染入口。外层 Stack 容器实现了三层叠加:底层是 Column 包含页面内容和底部导航栏,中层是粒子特效层,顶层是弹框遮罩层(条件渲染)。这种 Z 轴分层设计确保了粒子飘浮在内容之上、弹框遮罩覆盖一切的正确视觉层次。

页面内容区使用 if-else if 链式条件渲染实现底部 Tab 路由。当 currentBottomTabHOME 时,渲染首页结构(包含 homeHeadershopTabBar 和功能 Tab 内容组件);为 CART 时渲染 CartContent;其他 Tab 类似。在首页内部,进一步通过 if-else if 链实现顶部功能 Tab 路由,根据 currentShopTab 的值渲染不同的内容组件。子组件通过参数传递接收数据和回调函数。回调函数采用箭头函数形式,确保 this 绑定到 MarketPage 实例。例如 onAddCart 回调在子组件中被调用时,会修改 MarketPageselectedProductshowCartModal 状态,从而触发弹框显示。这种"状态提升 + 回调下传"的模式是 ArkTS 组件间通信的标准范式。CouponContentonTakeCoupon 回调展示了不可变更新模式:先 slice() 拷贝数组,修改副本,再赋值给 @State,确保状态变更可被框架检测。


代码段19:首页头部搜索栏

@Builder
homeHeader() {
  Column() {
    Row() {
      Text('QQ')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
      Text('潮流集市')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
      Column().layoutWeight(1)
      Stack({ alignContent: Alignment.TopEnd }) {
        Text('🛒')
          .fontSize(22)
          .onClick(() => {
            this.currentBottomTab = BottomTab.CART
          })
        if (this.cartCount() > 0) {
          Text(this.cartCount().toString())
            .fontSize(10)
            .fontColor(Color.White)
            .fontWeight(FontWeight.Bold)
            .backgroundColor(COLORS.red)
            .borderRadius(8)
            .constraintSize({ minWidth: 16, minHeight: 16 })
            .padding({ left: 4, right: 4 })
            .textAlign(TextAlign.Center)
            .translate({ x: 6, y: -6 })
        }
      }
      .width(36)
      .height(36)
    }
    .width('100%')

    Row() {
      Text('🔍')
        .fontSize(16)
        .margin({ left: 12, right: 6 })
      TextInput({ placeholder: '搜索爆款好物 / 品牌 / 店铺', text: this.searchKey })
        .placeholderColor('#FFFFFF')
        .placeholderFont({ size: 13 })
        .fontColor(Color.White)
        .fontSize(13)
        .backgroundColor('rgba(255,255,255,0.2)')
        .borderRadius(18)
        .height(36)
        .padding({ left: 4, right: 12 })
        .layoutWeight(1)
        .onChange((value: string) => {
          this.searchKey = value
        })
    }
    .width('100%')
    .height(40)
    .borderRadius(20)
    .backgroundColor('rgba(255,255,255,0.18)')
    .margin({ top: 10 })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 10 })
  .linearGradient({
    angle: 135,
    colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]]
  })
}

@Builder 装饰器将 homeHeader 方法定义为一个可复用的 UI 构建器。在 ArkTS API 24 中,@Builder 方法可以被组件内部多次调用,实现 UI 片段的复用,且不会产生独立组件实例的开销。头部区域由两部分组成:顶栏(Logo + 购物车入口 + 消息入口)和搜索栏。

购物车图标使用 Stack({ alignContent: Alignment.TopEnd }) 容器实现角标叠加效果。当 cartCount() > 0 时,在 Stack 的右上角渲染一个红色圆形角标,显示购物车商品总件数。constraintSize({ minWidth: 16, minHeight: 16 }) 确保角标在个位数时为圆形(16x16),多位数时自动扩展宽度。translate({ x: 6, y: -6 }) 将角标向右上偏移,使其部分溢出图标区域,形成经典的"消息气泡"视觉效果。搜索栏使用 TextInput 组件,配置了白色半透明背景和圆角,与头部渐变背景融合。linearGradient 属性接受 anglecolors 数组,从左上 135 度方向到右下渲染从 COLORS.primary(亮橙)到 COLORS.primaryDark(深橙)的线性渐变,营造温暖的品牌氛围。onChange 回调实时更新 searchKey 状态,为后续搜索功能预留接口。


代码段20:顶部功能Tab与底部导航Tab

@Builder
shopTabBar() {
  Row() {
    ForEach(SHOP_TAB_LABELS, (label: string, index: number) => {
      Text(label)
        .fontSize(index === this.currentShopTab ? 15 : 13)
        .fontWeight(index === this.currentShopTab ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(index === this.currentShopTab ? COLORS.primary : COLORS.sub)
        .backgroundColor(index === this.currentShopTab ? COLORS.primaryLight : Color.Transparent)
        .borderRadius(14)
        .height(28)
        .padding({ left: 12, right: 12 })
        .textAlign(TextAlign.Center)
        .onClick(() => {
          this.currentShopTab = index
        })
    }, (label: string) => label)
  }
  .width('100%')
  .justifyContent(FlexAlign.SpaceEvenly)
  .padding({ top: 8, bottom: 8 })
  .backgroundColor(COLORS.bg)
}

@Builder
bottomTabBar() {
  Row() {
    ForEach(BOTTOM_TAB_META, (tab: KingKongEntry, index: number) => {
      Column() {
        Stack({ alignContent: Alignment.TopEnd }) {
          Text(tab.icon)
            .fontSize(22)
            .opacity(this.currentBottomTab === index ? 1 : 0.55)
          if (index === BottomTab.CART && this.cartCount() > 0) {
            Text(this.cartCount().toString())
              .fontSize(9)
              .fontColor(Color.White)
              .fontWeight(FontWeight.Bold)
              .backgroundColor(COLORS.red)
              .borderRadius(8)
              .constraintSize({ minWidth: 14, minHeight: 14 })
              .padding({ left: 3, right: 3 })
              .textAlign(TextAlign.Center)
              .translate({ x: 8, y: -4 })
          }
        }
        .height(26)
        Text(tab.label)
          .fontSize(11)
          .fontColor(this.currentBottomTab === index ? COLORS.primary : COLORS.sub)
          .fontWeight(this.currentBottomTab === index ? FontWeight.Bold : FontWeight.Normal)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.currentBottomTab = index
      })
    }, (tab: KingKongEntry) => tab.label)
  }
  .width('100%')
  .height(58)
  .backgroundColor(COLORS.cardBg)
  .border({ width: 1, color: COLORS.border })
}

两个 @Builder 方法分别构建顶部功能 Tab 栏和底部导航 Tab 栏。顶部 Tab 栏使用 Row + ForEach 渲染 6 个标签文字,通过 justifyContent(FlexAlign.SpaceEvenly) 实现等间距分布。选中态与未选中态通过三元运算符动态设置字体大小(15 vs 13)、字重(Bold vs Normal)、文字颜色(主色橙 vs 灰色)和背景色(浅橙 vs 透明)。这种条件样式是 ArkTS 声明式 UI 的典型写法——在属性链上使用表达式而非在 CSS 类之间切换。

底部导航栏的结构更加复杂。每个 Tab 项是一个 Column,上方是图标区域(使用 Stack 叠加角标),下方是标签文字。选中态通过 opacity(1 vs 0.55)和颜色/字重差异来表现。购物车 Tab 特别处理了角标逻辑——仅当 index === BottomTab.CARTcartCount() > 0 时才渲染红色数字角标。ForEach 的第三个参数是键值生成器(key generator),顶部 Tab 使用 label 作为键,底部 Tab 使用 tab.label 作为键。键值生成器在 ArkTS 的 ForEach 中用于 Diff 算法——当数据变化时,框架通过键值判断哪些元素是新增、删除或位置变化,从而最小化 DOM 操作。底部导航栏高度设为 58px,使用 border 在顶部添加 1px 分割线,视觉上与内容区分离。


代码段21:粒子层与弹框遮罩

@Builder
particleLayer() {
  ForEach(this.particles, (dot: ParticleDot, index: number) => {
    Text(dot.icon)
      .fontSize(dot.size)
      .opacity(dot.y < 15 ? Math.max(0, dot.y / 15) * 0.4 : 0.4)
      .position({ x: `${dot.x}%`, y: `${dot.y}%` })
  }, (dot: ParticleDot, index: number) => index.toString() + '_' + dot.y.toFixed(1))
}

@Builder
modalOverlay() {
  Column() {
    if (this.showCartModal) {
      this.addToCartModal()
    } else if (this.showAddressModal) {
      this.addressModal()
    } else if (this.showDeleteModal) {
      this.deleteConfirmModal()
    } else if (this.showDetailModal) {
      this.productDetailModal()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.mask)
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.closeAllModals()
  })
}

particleLayer 是粒子特效的渲染入口。ForEach 遍历 this.particles 数组,为每个粒子渲染一个 Text 组件显示 Emoji 图标。fontSize 设置为粒子的 size 属性(14-32px)。opacity 使用条件表达式实现淡入效果:当粒子位于顶部 15% 区域内时(dot.y < 15),透明度随 y 值线性增加(dot.y / 15 * 0.4),形成从底部飘出时渐显、到达顶部时渐隐的视觉效果。position 属性使用百分比坐标定位粒子在屏幕中的位置。

键值生成器 (dot, index) => index.toString() + '_' + dot.y.toFixed(1) 是一个精心设计的复合键。由于粒子动画中每个粒子的 y 坐标每帧都在变化,将 y 值纳入键值可以确保 ForEach 的 Diff 算法正确识别粒子的位置变化。如果仅使用 index 作为键,框架可能会优化掉位置更新,导致粒子不移动。toFixed(1) 将 y 值精度限制为一位小数,避免键值过于频繁变化导致的性能开销。modalOverlay 是弹框遮罩层,使用半透明深色背景(COLORS.mask)覆盖全屏。内部通过 if-else if 链根据四个布尔状态变量决定渲染哪个弹框。遮罩层的 onClick 绑定到 closeAllModals(),实现"点击遮罩区域关闭弹框"的交互。


代码段22:加入购物车弹框

@Builder
addToCartModal() {
  Column() {
    Column() {
      Text('🛍️')
        .fontSize(28)
      Text('加入购物车')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ top: 20, bottom: 24 })
    .linearGradient({
      angle: 135,
      colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]]
    })
    .borderRadius({ topLeft: 20, topRight: 20 })

    Row() {
      Text(this.selectedProduct.icon)
        .fontSize(40)
        .width(76)
        .height(76)
        .backgroundColor(this.selectedProduct.coverColor)
        .borderRadius(14)
        .textAlign(TextAlign.Center)
      Column() {
        Text(this.selectedProduct.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Row() {
          Text('¥')
            .fontSize(12)
            .fontColor(COLORS.red)
            .fontWeight(FontWeight.Bold)
          Text(formatPrice(this.selectedProduct.price))
            .fontSize(22)
            .fontColor(COLORS.red)
            .fontWeight(FontWeight.Bold)
          Text('¥' + formatPrice(this.selectedProduct.originalPrice))
            .fontSize(12)
            .fontColor(COLORS.faint)
            .decoration({ type: TextDecorationType.LineThrough })
            .margin({ left: 8 })
        }
        .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 12 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16 })
    // ... 规格选择、数量选择、确定按钮
  }
  .width('86%')
  .borderRadius(20)
  .backgroundColor(COLORS.cardBg)
  .clip(true)
  .onClick(() => {
  })
}

加入购物车弹框是最复杂的弹框之一,它由四个区域组成:渐变标题区、商品信息区、规格选择区和数量选择区。标题区使用与首页头部相同的橙色渐变,仅顶部圆角(borderRadius: { topLeft: 20, topRight: 20 }),与外层容器的 clip(true) 配合,实现"上方圆角下方直角"的视觉效果。

商品信息区采用左图右文的布局。左侧 76x76 的方形区域显示商品 Emoji 图标,背景色为商品 coverColor。右侧包含商品名称(最多两行,超出省略)、价格行和库存信息。价格行使用三种 Text 组件拼接:小号"¥"符号 + 大号价格数字 + 删除线原价。TextDecorationType.LineThrough 实现文字删除线效果,这是电商价格展示的经典模式。maxLines(2)textOverflow({ overflow: TextOverflow.Ellipsis }) 组合确保商品名称过长时优雅截断。弹框最外层的 onClick 设置为空函数体——这是阻止事件冒泡的技巧。由于遮罩层的 onClick 绑定了 closeAllModals,弹框自身的点击事件如果不"消费"掉,会冒泡到遮罩层导致弹框意外关闭。空函数体的 onClick 阻止了冒泡,确保用户在弹框内部的交互不会触发关闭。


代码段23:地址编辑弹框与删除确认弹框

@Builder
addressModal() {
  Column() {
    Row() {
      Text('📍')
        .fontSize(20)
      Text('编辑收货地址')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
        .margin({ left: 6 })
      Column().layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor(COLORS.faint)
        .padding(6)
        .onClick(() => {
          this.showAddressModal = false
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 12 })
    .border({ width: 1, color: COLORS.border })

    Scroll() {
      Column() {
        Row() {
          Text('收货人')
            .fontSize(13)
            .fontColor(COLORS.sub)
            .width(64)
          TextInput({ placeholder: '请输入收货人姓名', text: this.addrName })
            .fontSize(13)
            .fontColor(COLORS.ink)
            .height(40)
            .backgroundColor(Color.Transparent)
            .layoutWeight(1)
            .onChange((value: string) => {
              this.addrName = value
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16 })
        .border({ width: 1, color: COLORS.border })
        // ... 手机号、所在地区、详细地址、标签
      }
      .width('100%')
    }
    .constraintSize({ maxHeight: 360 })
    .layoutWeight(1)
  }
  .width('88%')
  .borderRadius(16)
  .backgroundColor(COLORS.cardBg)
  .clip(true)
  .onClick(() => {
  })
}

@Builder
deleteConfirmModal() {
  Column() {
    Column() {
      Text('🗑️')
        .fontSize(34)
        .width(64)
        .height(64)
        .textAlign(TextAlign.Center)
        .backgroundColor('rgba(230,57,70,0.12)')
        .borderRadius(32)
    }
    .margin({ top: 24 })

    Text('确定删除该商品吗?')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.ink)
      .margin({ top: 14 })

    Text('删除后购物车将不再展示该商品,相关优惠可能失效')
      .fontSize(12)
      .fontColor(COLORS.sub)
      .margin({ top: 8 })
      .padding({ left: 24, right: 24 })
      .textAlign(TextAlign.Center)

    Row() {
      Text('再想想')
        .fontSize(14)
        .fontColor(COLORS.sub)
        .height(40)
        .textAlign(TextAlign.Center)
        .layoutWeight(1)
        .backgroundColor(COLORS.bg)
        .borderRadius(20)
        .onClick(() => {
          this.showDeleteModal = false
        })
      Column().layoutWeight(1)
      Text('删除')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .height(40)
        .textAlign(TextAlign.Center)
        .layoutWeight(1)
        .backgroundColor(COLORS.red)
        .borderRadius(20)
        .onClick(() => {
          this.confirmDelete()
        })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 22, bottom: 22 })
  }
  .width('72%')
  .borderRadius(16)
  .backgroundColor(COLORS.cardBg)
  .clip(true)
  .onClick(() => {
  })
}

地址编辑弹框是一个表单型弹框,使用 Scroll 容器包裹表单内容,并通过 constraintSize({ maxHeight: 360 }) 限制最大高度。当表单内容超出可视区域时,用户可以滚动查看。每个表单字段采用 Row 布局:左侧是 64px 宽的标签文字,右侧是 TextInput 输入框或选择器。TextInputbackgroundColor 设为 Color.Transparent,使其融入行背景。手机号字段使用 .type(InputType.PhoneNumber) 调用系统级数字键盘。标签选择区域使用 ForEach 渲染三个标签按钮(家、公司、学校),选中态通过背景色和文字色差异表现。

删除确认弹框是一个小型警示弹框,宽度仅占屏幕的 72%。顶部是一个 64x64 的圆形图标容器,背景色为红色半透明,传达删除操作的警示语义。中间是标题和说明文字,底部是"再想想"和"删除"双按钮。两个按钮使用 layoutWeight(1) 等分宽度,中间通过 Column().layoutWeight(1) 添加弹性间隔。"删除"按钮使用红色背景和白色粗体文字,"再想想"按钮使用浅灰背景和灰色文字,形成主次按钮的视觉层次。"删除"按钮的 onClick 调用 this.confirmDelete() 执行实际的删除操作并关闭弹框。


代码段24:商品详情弹框

@Builder
productDetailModal() {
  Column() {
    Scroll() {
      Column() {
        Stack({ alignContent: Alignment.TopStart }) {
          Swiper() {
            ForEach(['#FFE5D9', '#FFEDE4', '#FDE3E5'], (bg: string, index: number) => {
              Column() {
                Text(this.selectedProduct.icon)
                  .fontSize(72)
                Text(this.selectedProduct.brand + ' · 官方正品')
                  .fontSize(12)
                  .fontColor(COLORS.sub)
                  .margin({ top: 8 })
              }
              .width('100%')
              .height(200)
              .justifyContent(FlexAlign.Center)
              .backgroundColor(bg)
            }, (bg: string) => bg)
          }
          .width('100%')
          .height(200)
          .indicator(true)
          .onChange((index: number) => {
            this.detailSlideIndex = index
          })

          Text('✕')
            .fontSize(14)
            .fontColor(Color.White)
            .width(28)
            .height(28)
            .textAlign(TextAlign.Center)
            .backgroundColor('rgba(26,26,46,0.4)')
            .borderRadius(14)
            .margin(10)
            .onClick(() => {
              this.showDetailModal = false
            })
        }
        .width('100%')

        Column() {
          Row() {
            Text('¥')
              .fontSize(14)
              .fontColor(COLORS.red)
              .fontWeight(FontWeight.Bold)
            Text(formatPrice(this.selectedProduct.price))
              .fontSize(28)
              .fontColor(COLORS.red)
              .fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text(formatSales(this.selectedProduct.salesCount) + '人已抢')
              .fontSize(12)
              .fontColor(COLORS.sub)
          }
          .width('100%')
          // ... 原价、折扣、名称、标签、描述、参数、评价摘要
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')
        .padding(16)
        .backgroundColor(COLORS.cardBg)

        Row() {
          Column() {
            Text('客服')
              .fontSize(11)
              .fontColor(COLORS.sub)
            Text('💬')
              .fontSize(20)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Center)
          Text('加入购物车')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
            .height(42)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .backgroundColor(COLORS.primaryLight)
            .borderRadius(21)
            .margin({ left: 12 })
            .onClick(() => {
              this.showDetailModal = false
              this.showCartModal = true
            })
          Text('立即抢购')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
            .height(42)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .backgroundColor(COLORS.primary)
            .borderRadius(21)
            .margin({ left: 10 })
            .onClick(() => {
              this.showDetailModal = false
              this.currentBottomTab = BottomTab.CART
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 20 })
      }
      .width('100%')
    }
    .constraintSize({ maxHeight: '80%' })
    .scrollBar(BarState.Off)
  }
  .width('92%')
  .borderRadius(20)
  .backgroundColor(COLORS.bg)
  .clip(true)
  .onClick(() => {
  })
}

商品详情弹框是四个弹框中信息量最大的一个。它使用 Scroll 容器实现内容滚动,最大高度限制为屏幕的 80%,确保弹框不会完全遮挡底层页面。scrollBar(BarState.Off) 隐藏滚动条,保持视觉简洁。

弹框内容由上至下分为六个区块。顶部是图片轮播区,使用 Swiper 组件实现三页轮播,每页展示商品图标和品牌信息,背景色分别为三种浅色。indicator(true) 显示底部圆点指示器,onChange 回调更新 detailSlideIndex 状态。轮播区上方叠加了一个半透明圆形关闭按钮,使用 Stack({ alignContent: Alignment.TopStart }) 定位在左上角。第二区块是价格信息区,展示当前价格(28px 大号红色粗体)、原价(删除线)、折扣标签、商品名称、标签胶囊和商品描述。第三区块是商品参数表,以行布局展示品牌、分类、发货地和库存。第四区块是评价摘要,包含综合评分(金色大号数字)、五星评级、评价数量、好评率和一条用户评价。底部是操作按钮栏,包含客服按钮、"加入购物车"按钮(浅橙背景)和"立即抢购"按钮(橙色实心背景)。两个按钮的 onClick 分别关闭详情弹框并打开购物车弹框或切换到购物车 Tab。这种"关闭当前弹框 + 打开新弹框/页面"的链式操作模式在 ArkTS 中通过连续修改多个 @State 变量实现。


代码段25:精选内容组件 FeaturedContent

@Component
struct FeaturedContent {
  onAddCart: (product: ProductItem) => void = (product: ProductItem) => {
  }
  onOpenDetail: (product: ProductItem) => void = (product: ProductItem) => {
  }

  build() {
    Scroll() {
      Column() {
        Swiper() {
          ForEach(BANNERS, (banner: BannerInfo) => {
            Row() {
              Column() {
                Text(banner.tag)
                  .fontSize(10)
                  .fontColor(COLORS.primary)
                  .backgroundColor(Color.White)
                  .borderRadius(8)
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                Text(banner.title)
                  .fontSize(20)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(Color.White)
                  .margin({ top: 8 })
                Text(banner.sub)
                  .fontSize(12)
                  .fontColor('rgba(255,255,255,0.85)')
                  .margin({ top: 4 })
                Row() {
                  Text('¥')
                    .fontSize(11)
                    .fontColor(Color.White)
                    .fontWeight(FontWeight.Bold)
                  Text(banner.price)
                    .fontSize(18)
                    .fontColor(Color.White)
                    .fontWeight(FontWeight.Bold)
                  Text('起 >')
                    .fontSize(11)
                    .fontColor(Color.White)
                    .margin({ left: 4 })
                }
                .margin({ top: 10 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .padding({ left: 16 })

              Text(banner.icon)
                .fontSize(56)
                .margin({ right: 24 })
            }
            .width('100%')
            .height(120)
            .borderRadius(14)
            .backgroundColor(banner.bg)
            .margin({ bottom: 2 })
          }, (banner: BannerInfo) => banner.title)
        }
        .width('100%')
        .height(124)
        .autoPlay(true)
        .indicator(true)
        .margin({ left: 12, right: 12 })

        Row() {
          ForEach(KINGKONG_ENTRIES, (entry: KingKongEntry) => {
            Column() {
              Text(entry.icon)
                .fontSize(24)
                .width(44)
                .height(44)
                .textAlign(TextAlign.Center)
                .backgroundColor(entry.bg)
                .borderRadius(22)
              Text(entry.label)
                .fontSize(11)
                .fontColor(COLORS.ink)
                .margin({ top: 6 })
            }
            .layoutWeight(1)
            .onClick(() => {
            })
          }, (entry: KingKongEntry) => entry.label)
        }
        .width('100%')
        .padding({ left: 8, right: 8, top: 14, bottom: 14 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .margin({ left: 12, right: 12, top: 12 })
        // ... 爆款横滑列表、推荐列表
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .width('100%')
  }
}

FeaturedContent 是首页精选 Tab 的内容组件,也是应用中内容最丰富的组件之一。它由四个主要区块组成:Banner 轮播、金刚区宫格、爆款横滑列表和推荐商品列表。Banner 轮播使用 Swiper 组件,配置 autoPlay(true) 实现自动轮播,每张 Banner 采用左文右图布局——左侧依次展示标签、标题、副标题和起价信息,右侧展示 56px 的大号 Emoji 图标。

金刚区使用 Row + ForEach 渲染 8 个功能入口,每个入口是一个 Column 包含圆形图标(44x44,背景色为 entry.bg)和标签文字。8 个入口通过 layoutWeight(1) 等分宽度,形成 4 列 2 行的宫格效果。爆款横滑列表使用嵌套 Scroll(设置 scrollable(ScrollDirection.Horizontal))实现横向滚动,每个商品卡固定宽度 140px,包含图标区、名称、价格和加购按钮。推荐列表使用 ForEach 遍历全部 15 个商品,以左右布局的卡片形式展示,包含图标、名称、描述、状态标签、价格和销量信息。组件通过 onAddCartonOpenDetail 两个回调函数与父组件通信。回调函数的默认值是空箭头函数,确保即使父组件未传入回调也不会运行时报错。这是 ArkTS 组件设计的防御性编程实践。外层 Scroll 使用 layoutWeight(1) 填充剩余空间,配合 scrollBar(BarState.Off) 提供干净的滚动体验。


代码段26:闪购内容组件 FlashContent

@Component
struct FlashContent {
  flashHourText: string = '00'
  flashMinuteText: string = '00'
  flashSecondText: string = '00'
  onAddCart: (product: ProductItem) => void = (product: ProductItem) => {
  }
  onOpenDetail: (product: ProductItem) => void = (product: ProductItem) => {
  }

  @Builder
  flashTimeBox(text: string) {
    Text(text)
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor(Color.White)
      .width(38)
      .height(34)
      .textAlign(TextAlign.Center)
      .backgroundColor(COLORS.ink)
      .borderRadius(6)
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Row() {
              Text('⚡')
                .fontSize(20)
              Text('限时闪购')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(Color.White)
                .margin({ left: 6 })
            }
            Text('全场低至5折 · 手慢无')
              .fontSize(11)
              .fontColor('rgba(255,255,255,0.85)')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Column() {
            Text('距结束')
              .fontSize(10)
              .fontColor('rgba(255,255,255,0.85)')
            Row() {
              this.flashTimeBox(this.flashHourText)
              Text(':')
                .fontSize(16)
                .fontColor(Color.White)
                .fontWeight(FontWeight.Bold)
                .margin({ left: 4, right: 4 })
              this.flashTimeBox(this.flashMinuteText)
              Text(':')
                .fontSize(16)
                .fontColor(Color.White)
                .fontWeight(FontWeight.Bold)
                .margin({ left: 4, right: 4 })
              this.flashTimeBox(this.flashSecondText)
            }
            .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 120,
          colors: [[COLORS.red, 0], [COLORS.primaryDark, 1]]
        })
        .margin({ left: 12, right: 12, top: 12 })

        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(flashProducts(), (product: ProductItem) => {
            Column() {
              Stack({ alignContent: Alignment.TopStart }) {
                Text(product.icon)
                  .fontSize(44)
                  .width(160)
                  .height(120)
                  .textAlign(TextAlign.Center)
                  .backgroundColor(product.coverColor)
                Text('-' + Math.round((1 - product.price / product.originalPrice) * 100).toString() + '%')
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(Color.White)
                  .backgroundColor(COLORS.red)
                  .borderRadius({ topRight: 10, bottomRight: 10 })
                  .padding({ left: 8, right: 8, top: 4, bottom: 4 })
              }
              .width(160)
              // ... 商品信息、进度条、抢购按钮
            }
            .width(160)
            .borderRadius(12)
            .backgroundColor(COLORS.cardBg)
            .margin({ top: 8 })
            .onClick(() => {
              this.onOpenDetail(product)
            })
          }, (product: ProductItem) => 'flash_' + product.id.toString())
        }
        .width('100%')
        .padding({ left: 12, right: 12 })
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .width('100%')
  }
}

FlashContent 组件接收三个时间文本参数(flashHourTextflashMinuteTextflashSecondText),这些参数由父组件 MarketPageflashHour()flashMinute()flashSecond() 方法计算后传入。这种设计将倒计时逻辑集中在父组件中,子组件仅负责展示,符合"状态提升"原则。@Builder flashTimeBox 是一个局部 UI 构建器,用于渲染倒计时数字方块——深色背景(COLORS.ink)的圆角矩形,白色粗体数字居中。通过调用 this.flashTimeBox(this.flashHourText) 复用该构建器三次,分别渲染时、分、秒。

闪购头部区域使用红橙渐变(COLORS.redCOLORS.primaryDark),传达限时抢购的紧迫感。商品列表使用 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 实现两列自动换行布局。每个商品卡片固定宽度 160px,FlexSpaceBetween 对齐使得两列卡片自动分布到屏幕两侧,中间留出间距。卡片顶部使用 Stack 叠加了折扣标签——红色背景的左上角标签显示折扣百分比(如"-44%"),通过 Math.round((1 - product.price / product.originalPrice) * 100) 计算得出。卡片中还包含进度条组件,使用两个 Column 嵌套实现:外层是浅色背景的固定宽度容器,内层是金色背景的可变宽度填充条,宽度通过 product.stock / (product.stock + 60) * 74 动态计算,模拟"已抢购比例"的可视化效果。


代码段27:品牌与优惠券组件

@Component
struct BrandContent {
  @State selectedBrand: string = BRANDS[0].name
  onOpenDetail: (product: ProductItem) => void = (product: ProductItem) => {
  }

  build() {
    Scroll() {
      Column() {
        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(BRANDS, (brand: BrandMeta) => {
            Column() {
              Text(brand.icon)
                .fontSize(26)
                .width(54)
                .height(54)
                .textAlign(TextAlign.Center)
                .backgroundColor(this.selectedBrand === brand.name ? brand.color : brand.bg)
                .borderRadius(27)
              Text(brand.name)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.selectedBrand === brand.name ? brand.color : COLORS.ink)
                .margin({ top: 6 })
              Text(brand.fans + '粉丝')
                .fontSize(9)
                .fontColor(COLORS.faint)
                .margin({ top: 2 })
            }
            .width('30%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(14)
            .backgroundColor(this.selectedBrand === brand.name ? brand.bg : COLORS.cardBg)
            .margin({ top: 8 })
            .onClick(() => {
              this.selectedBrand = brand.name
            })
          }, (brand: BrandMeta) => brand.name)
        }
        // ... 品牌商品列表
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .width('100%')
  }
}

@Component
struct CouponContent {
  takenFlags: boolean[] = [false, false, false, false, false]
  onTakeCoupon: (index: number) => void = (index: number) => {
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🧧')
            .fontSize(34)
          Column() {
            Text('新人大礼包')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(Color.White)
            Text('注册即领 518元 组合券')
              .fontSize(11)
              .fontColor('rgba(255,255,255,0.85)')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Text('一键领取')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
            .height(30)
            .textAlign(TextAlign.Center)
            .padding({ left: 14, right: 14 })
            .backgroundColor(Color.White)
            .borderRadius(15)
        }
        .width('100%')
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 120,
          colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]]
        })

        ForEach(COUPONS, (coupon: CouponMeta, index: number) => {
          Row() {
            Column() {
              Row() {
                Text('¥')
                  .fontSize(14)
                  .fontColor(coupon.color)
                  .fontWeight(FontWeight.Bold)
                Text(coupon.value)
                  .fontSize(32)
                  .fontColor(coupon.color)
                  .fontWeight(FontWeight.Bold)
              }
              Text('满' + (parseInt(coupon.value) * 4).toString() + '可用')
                .fontSize(10)
                .fontColor(coupon.color)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .width(110)
            // ... 券描述、领取按钮
          }
          .width('100%')
          .padding({ top: 14, bottom: 14 })
          .borderRadius(14)
          .backgroundColor(COLORS.cardBg)
          .border({ width: 1, color: coupon.bg })
          .margin({ left: 12, right: 12, top: 10 })
        }, (coupon: CouponMeta) => coupon.title)
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .width('100%')
  }

  takenCount(): number {
    let count: number = 0
    for (let i = 0; i < this.takenFlags.length; i++) {
      if (this.takenFlags[i]) {
        count++
      }
    }
    return count
  }
}

BrandContent 组件包含一个 @State selectedBrand 状态,初始值为第一个品牌名。品牌宫格使用 Flex 三列布局,每个品牌卡片宽度为 30%。选中品牌时,图标背景变为品牌主题色(brand.color),卡片背景变为品牌浅色(brand.bg),文字颜色也变为品牌主题色。品牌列表区域通过 brandProducts(this.selectedBrand) 函数动态过滤当前选中品牌下的商品,实现联动效果。这种"选择品牌 -> 列表过滤"的模式是电商品牌专区的标准交互。

CouponContent 组件接收 takenFlags 布尔数组和 onTakeCoupon 回调。顶部是一个橙色渐变的新人大礼包 Banner,底部是五张优惠券卡片列表。每张优惠券采用左金额右描述的布局:左侧 110px 宽的区域展示面额数字(32px 大号粗体)和满减门槛,右侧展示券标题、描述和领取状态。优惠券领取逻辑通过 if-else 条件渲染实现——已领取时显示灰色"已领取"按钮,未领取时显示彩色"立即领取"按钮。takenCount() 方法统计已领取的券数量,显示在标题栏右侧。parseInt(coupon.value) * 4 计算满减门槛(如面额 50 元需满 200 元),将业务规则嵌入展示逻辑中。onTakeCoupon(index) 回调将领取状态变化传递给父组件,父组件通过不可变更新方式修改 couponTaken 数组。


代码段28:新品与排行组件

@Component
struct NewContent {
  onAddCart: (product: ProductItem) => void = (product: ProductItem) => {
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Text(TIMELINE_ITEMS[0].icon)
            .fontSize(30)
          Column() {
            Text('今日重磅新品 ' + TIMELINE_ITEMS[0].time + ' 开售')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(Color.White)
            Text('预约人数已突破 2.3万,开售前提醒不错过')
              .fontSize(10)
              .fontColor('rgba(255,255,255,0.85)')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })
        }
        .width('100%')
        .padding(14)
        .borderRadius(14)
        .backgroundColor(COLORS.green)

        Column() {
          ForEach(TIMELINE_ITEMS, (item: TimeLineItem, index: number) => {
            Row() {
              Column() {
                Text(item.time.split(' ')[1])
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.ink)
                Text(item.time.split(' ')[0])
                  .fontSize(10)
                  .fontColor(COLORS.faint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center)
              .width(64)

              Column() {
                Circle()
                  .width(12)
                  .height(12)
                  .fill(item.color)
                if (index < TIMELINE_ITEMS.length - 1) {
                  Column()
                    .width(2)
                    .layoutWeight(1)
                    .backgroundColor(COLORS.border)
                    .margin({ top: 4 })
                }
              }
              .width(20)
              .alignItems(HorizontalAlign.Center)
              // ... 商品信息、预约按钮
            }
            .width('100%')
          }, (item: TimeLineItem) => item.time)
        }
        // ... 可预约商品列表
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .width('100%')
  }
}

@Component
struct RankContent {
  rankProducts: ProductItem[] = []
  onOpenDetail: (product: ProductItem) => void = (product: ProductItem) => {
  }
  @State rankMode: string = '销量'

  build() {
    Scroll() {
      Column() {
        Row() {
          ForEach(['销量', '口碑', '热度'], (mode: string) => {
            Text(mode)
              .fontSize(12)
              .fontColor(this.rankMode === mode ? Color.White : COLORS.sub)
              .backgroundColor(this.rankMode === mode ? COLORS.primary : COLORS.bg)
              .borderRadius(14)
              .height(28)
              .padding({ left: 14, right: 14 })
              .textAlign(TextAlign.Center)
              .margin({ right: 8 })
              .onClick(() => {
                this.rankMode = mode
              })
          }, (mode: string) => mode)
        }

        Column() {
          Row() {
            ForEach(this.topEight(), (product: ProductItem) => {
              Column() {
                Text(formatSales(product.salesCount))
                  .fontSize(8)
                  .fontColor(COLORS.sub)
                  .margin({ bottom: 4 })
                Column()
                  .width(20)
                  .height(this.barHeight(product.salesCount))
                  .backgroundColor(this.barColor(product.id))
                  .borderRadius({ topLeft: 6, topRight: 6 })
                Text(product.icon)
                  .fontSize(12)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .justifyContent(FlexAlign.End)
              .height(170)
            }, (product: ProductItem) => 'bar_' + product.id.toString())
          }
        }

        ForEach(this.rankProducts, (product: ProductItem, index: number) => {
          Row() {
            if (index < 3) {
              Text(RANK_MEDALS[index].label)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(RANK_MEDALS[index].color)
                .width(28)
                .height(28)
                .textAlign(TextAlign.Center)
                .backgroundColor(RANK_MEDALS[index].bg)
                .borderRadius(14)
            } else {
              Text((index + 1).toString())
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.faint)
                .width(28)
                .height(28)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.bg)
                .borderRadius(14)
            }
            // ... 商品信息、进度条
          }
        })
      }
    }
  }

  topEight(): ProductItem[] {
    return this.rankProducts.slice(0, 8)
  }

  barHeight(sales: number): number {
    return Math.max(10, Math.round(sales / this.maxSales() * 110))
  }

  barColor(id: number): string {
    const palette: string[] = [COLORS.primary, COLORS.primaryDark, COLORS.gold, COLORS.red]
    return palette[id % palette.length]
  }
}

NewContent 组件的特色是新品时间轴。顶部使用绿色 Banner 突出展示今日重磅新品。时间轴通过 ForEach 渲染 TIMELINE_ITEMS,每条目的布局分为三列:左侧 64px 宽的时间信息(显示时分和日期),中间 20px 宽的圆点+连线(使用 Circle 组件绘制 12x12 的彩色圆点,圆点下方用 Column 绘制 2px 宽的连接线),右侧的商品信息和预约按钮。连接线仅在非最后一个条目时渲染(index < TIMELINE_ITEMS.length - 1),避免末尾出现多余的线条。Circle 是 ArkTS 的基础图形组件,通过 .fill(item.color) 设置填充色,实现每条时间轴节点颜色与商品主题色的呼应。

RankContent 组件包含排行榜的核心可视化——柱状图。柱状图通过 ForEach 渲染 topEight()(前 8 名商品),每个柱子是一个 Column,高度由 barHeight 计算得出。柱子从底部对齐(justifyContent(FlexAlign.End)),容器高度固定 170px,柱子上方显示销量文字,下方显示商品图标。barColor 方法通过 id % palette.length 在四种颜色中循环选择柱子颜色,形成彩色柱状图效果。排行榜列表的前三名使用 RANK_MEDALS 配置的金/橙/深橙奖牌样式,第四名以后使用灰色数字编号。每条商品还包含一个进度条,宽度为 product.salesCount / this.rankProducts[0].salesCount * 60,以榜首销量为基准的相对比例展示。@State rankMode 控制排序模式标签的选中状态,三个模式按钮(销量、口碑、热度)通过条件样式区分。


代码段29:分类、购物车与我的页面组件

@Component
struct CategoryContent {
  @State selectedCategory: string = '数码'
  onOpenDetail: (product: ProductItem) => void = (product: ProductItem) => {
  }

  build() {
    Column() {
      Row() {
        Text('🗂️ 全部分类')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
        Column().layoutWeight(1)
        Text('🔍')
          .fontSize(20)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .backgroundColor(COLORS.primary)

      Row() {
        Scroll() {
          Column() {
            ForEach(CATEGORY_KEYS, (key: string) => {
              Column() {
                Text(CATEGORY_MAP[key].icon)
                  .fontSize(20)
                Text(CATEGORY_MAP[key].label)
                  .fontSize(11)
                  .fontColor(this.selectedCategory === key ? COLORS.primary : COLORS.sub)
                  .margin({ top: 4 })
              }
              .width('100%')
              .padding({ top: 14, bottom: 14 })
              .backgroundColor(this.selectedCategory === key ? COLORS.cardBg : Color.Transparent)
              .onClick(() => {
                this.selectedCategory = key
              })
            }, (key: string) => key)
          }
          .width('100%')
        }
        .scrollBar(BarState.Off)
        .width(76)

        Scroll() {
          Column() {
            Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
              ForEach(PRODUCT_LIST, (product: ProductItem) => {
                if (product.category === this.selectedCategory) {
                  Column() {
                    Text(product.icon)
                      .fontSize(38)
                      .width(140)
                      .height(110)
                      .textAlign(TextAlign.Center)
                      .backgroundColor(product.coverColor)
                      .borderRadius({ topLeft: 12, topRight: 12 })
                    // ... 名称、价格、状态
                  }
                  .width(140)
                  .borderRadius(12)
                  .backgroundColor(COLORS.cardBg)
                  .margin({ top: 8 })
                  .onClick(() => {
                    this.onOpenDetail(product)
                  })
                }
              }, (product: ProductItem) => 'cat_' + this.selectedCategory + '_' + product.id.toString())
            }
          }
        }
        .layoutWeight(6)
        .backgroundColor(COLORS.cardBg)
        .borderRadius({ topLeft: 16, topRight: 16 })
      }
      .layoutWeight(1)
    }
  }

  categoryCount(): number {
    return PRODUCT_LIST.filter((p: ProductItem) => p.category === this.selectedCategory).length
  }
}

@Component
struct CartContent {
  items: CartItem[] = []
  allSelected: boolean = false
  selectedCount: number = 0
  selectedTotal: number = 0
  onToggleItem: (id: number) => void = (id: number) => {
  }
  onToggleAll: () => void = () => {
  }
  onQuantity: (id: number, delta: number) => void = (id: number, delta: number) => {
  }
  onDelete: (id: number) => void = (id: number) => {
  }
  onGoHome: () => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('🛒 购物车')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
        Column().layoutWeight(1)
        Text('共' + this.totalQuantity().toString() + '件')
          .fontSize(12)
          .fontColor('rgba(255,255,255,0.85)')
      }
      .backgroundColor(COLORS.primary)

      if (this.items.length === 0) {
        Column() {
          Text('🛒').fontSize(52)
          Text('购物车还是空的').fontSize(14).fontColor(COLORS.sub).margin({ top: 12 })
          Text('去逛逛')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
            .backgroundColor(COLORS.primary)
            .borderRadius(19)
            .onClick(() => { this.onGoHome() })
        }
        .justifyContent(FlexAlign.Center)
        .layoutWeight(1)
      } else {
        Scroll() {
          Column() {
            ForEach(this.items, (item: CartItem) => {
              Column() {
                Row() {
                  Text(item.selected ? '✓' : '')
                    .width(20).height(20)
                    .backgroundColor(item.selected ? COLORS.primary : COLORS.bg)
                    .borderRadius(10)
                    .onClick(() => { this.onToggleItem(item.id) })
                  // ... 商品图标、名称、规格、价格、数量控制
                }
              }
              .borderRadius(14)
              .backgroundColor(COLORS.cardBg)
            }, (item: CartItem) => 'cart_' + item.id.toString() + '_' + item.quantity.toString())
          }
        }
        .layoutWeight(1)

        Row() {
          Row() {
            Text(this.allSelected ? '✓' : '')
              .width(20).height(20)
              .backgroundColor(this.allSelected ? COLORS.primary : COLORS.bg)
              .borderRadius(10)
            Text('全选').fontSize(12).fontColor(COLORS.sub).margin({ left: 6 })
          }
          .onClick(() => { this.onToggleAll() })

          Column().layoutWeight(1)

          Column() {
            Row() {
              Text('合计: ').fontSize(12).fontColor(COLORS.sub)
              Text('¥').fontSize(12).fontColor(COLORS.red).fontWeight(FontWeight.Bold)
              Text(formatPrice(this.selectedTotal)).fontSize(20).fontColor(COLORS.red).fontWeight(FontWeight.Bold)
            }
            Text('不含运费 · 已选' + this.selectedCount.toString() + '件')
              .fontSize(10).fontColor(COLORS.faint)
          }

          Text(this.selectedCount > 0 ? '去结算(' + this.selectedCount.toString() + ')' : '去结算')
            .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Color.White)
            .backgroundColor(this.selectedCount > 0 ? COLORS.primary : COLORS.faint)
            .borderRadius(21)
            .onClick(() => { })
        }
        .backgroundColor(COLORS.cardBg)
      }
    }
  }

  totalQuantity(): number {
    let total: number = 0
    for (let i = 0; i < this.items.length; i++) {
      total += this.items[i].quantity
    }
    return total
  }
}

@Component
struct MineContent {
  address: AddressInfo = { name: '橙子用户', phone: '', region: '', detail: '', tag: '家' }
  cartTotal: number = 0
  onEditAddress: () => void = () => {
  }
  @State userStats: string[] = ['12', '5', '3', '860']

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🍊').fontSize(38).width(64).height(64).borderRadius(32)
            Column() {
              Text('橙子会员_VIP3').fontSize(17).fontWeight(FontWeight.Bold).fontColor(Color.White)
              Text('ID: 20260823 · 成长值 3680').fontSize(11).fontColor('rgba(255,255,255,0.85)')
            }
            .layoutWeight(1).margin({ left: 12 })
            Text('⚙️').fontSize(20)
          }

          Row() {
            Column() {
              Text('余额').fontSize(11).fontColor('rgba(255,255,255,0.85)')
              Text('¥' + this.userStats[3]).fontSize(17).fontWeight(FontWeight.Bold).fontColor(Color.White)
            }
            .layoutWeight(1)
            // ... 优惠券、橙积分、购物车
          }
        }
        .linearGradient({ angle: 135, colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]] })

        Row() {
          ForEach(ORDER_STATUSES, (status: ProductStatusMeta) => {
            Column() {
              Text(status.icon).fontSize(22)
              Text(status.label).fontSize(11).fontColor(COLORS.ink).margin({ top: 6 })
            }
            .layoutWeight(1)
          })
        }
        .backgroundColor(COLORS.cardBg).borderRadius(14)

        Column() {
          Row() {
            Text('📍 默认收货地址').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.ink)
            Column().layoutWeight(1)
            Text('编辑').fontSize(12).fontColor(COLORS.primary)
              .onClick(() => { this.onEditAddress() })
          }
          Text(this.address.region + ' ' + this.address.detail).fontSize(12).fontColor(COLORS.sub)
        }
        .backgroundColor(COLORS.cardBg).borderRadius(14)

        Column() {
          ForEach(MINE_MENU, (menu: KingKongEntry) => {
            Row() {
              Text(menu.icon).fontSize(18)
              Text(menu.label).fontSize(13).fontColor(COLORS.ink).margin({ left: 10 })
              Column().layoutWeight(1)
              Text('›').fontSize(16).fontColor(COLORS.faint)
            }
            .padding({ top: 14, bottom: 14 })
            .border({ width: 1, color: COLORS.border })
          })
        }
        .backgroundColor(COLORS.cardBg).borderRadius(14)
      }
    }
  }
}

这三个组件分别实现了分类页、购物车页和我的页面。CategoryContent 采用左右分栏布局:左侧 76px 宽的分类导航栏(可垂直滚动),右侧通过 layoutWeight(6) 占据剩余空间的商品网格。分类导航使用 ForEach(CATEGORY_KEYS, ...) 渲染 6 个分类入口,选中态通过背景色和文字色区分。右侧商品网格使用 Flex 换行布局,通过 if (product.category === this.selectedCategory) 条件渲染仅展示当前选中分类的商品。categoryCount() 方法实时计算当前分类下的商品数量。

CartContent 组件是购物车页面的完整实现。空购物车时显示引导插画和"去逛逛"按钮(点击调用 onGoHome 回调返回首页)。非空时显示商品列表和底部结算栏。商品列表中每项的勾选框使用条件文字(item.selected ? '✓' : '')和条件背景色实现。数量控制按钮通过 onQuantity(item.id, -1)onQuantity(item.id, 1) 回调向父组件传递加减操作。ForEach 的键值包含 item.quantity,确保数量变化时列表项正确更新。底部结算栏包含全选按钮、合计金额和结算按钮,结算按钮在无选中商品时使用灰色背景,有选中时使用橙色背景并显示选中数量。

MineContent 组件展示用户信息、订单状态、收货地址和功能菜单。用户卡使用橙色渐变背景,展示头像、会员等级、ID 和四项统计数据(余额、优惠券、积分、购物车)。订单状态行通过 ForEach(ORDER_STATUSES) 渲染 5 个订单状态入口。地址卡片展示默认收货地址,"编辑"按钮触发 onEditAddress 回调打开地址编辑弹框。功能菜单通过 ForEach(MINE_MENU) 渲染 6 个菜单项,每项使用行布局展示图标、文字和右箭头。


四、技术对比分析

表格1:弹框实现方案对比

对比维度 条件渲染弹框(本方案) bindSheet 半模态 Router 路由跳转
实现方式 if-else 条件渲染 @Builder .bindSheet() 绑定 router.pushUrl()
状态管理 @State 布尔变量驱动 $show 属性绑定 路由栈管理
遮罩控制 手动 Column + mask色 系统自动半透明遮罩 全屏覆盖无遮罩
动画效果 无内置动画(需自定义) 系统弹簧动画 页面转场动画
事件穿透 onClick 阻止冒泡 系统自动处理 独立页面无穿透
嵌套弹框 支持(多条件叠加) 不支持嵌套 支持(路由栈)
数据传递 直接访问 @State 参数对象传递 params 传递
适用场景 多弹框互斥切换 单一表单/确认框 完整页面跳转

本应用选择条件渲染弹框方案的核心原因是需要 4 种弹框互斥切换,且弹框间存在数据联动(如详情弹框关闭后打开加购弹框)。条件渲染方案使得弹框间切换只需修改两个布尔变量即可完成,无需路由跳转的页面重建开销。

表格2:状态管理装饰器对比

装饰器 作用域 触发更新条件 典型用例 性能影响
@State 当前组件 值变化或引用变化 页面级路由状态、弹框开关 中等
@Prop 父->子单向 父组件对应状态变化 子组件接收只读数据
@Link 父<->子双向 任一端变化同步 双向表单绑定 中等
@Observed 类属性级别 属性赋值变化 可变数据模型(CartItem) 低-中
@ObjectLink 观察对象 @Observed 属性变化 嵌套对象深度监听 中等
@Provide 祖先->后代 provide 值变化 全局主题/用户信息
@Consume 后代<-祖先 对应 provide 变化 跨层级数据消费

本应用主要使用 @State 管理页面级状态,@Observed 标记可变数据模型(ProductItemCartItem)。未使用 @Prop/@Link 的原因是子组件通过普通成员属性接收数据和回调函数,依赖父组件重渲染时的参数更新来刷新。

表格3:列表渲染布局方案对比

布局方案 使用组件 换行支持 等分支持 典型场景 性能特征
Row + ForEach Row 不支持 layoutWeight 横向Tab栏、金刚区 轻量
Flex + ForEach Flex 支持(FlexWrap) SpaceBetween 商品网格、品牌宫格 中等
List + ForEach List 不支持 layoutWeight 长列表、消息列表 高效懒加载
Grid + ForEach Grid 自动换行 GridCol 规则网格图片墙 中等
Scroll+Column+ForEach Scroll 不支持 layoutWeight 混合内容长页面 取决于内容量

本应用根据场景灵活选择布局方案:金刚区使用 Row + layoutWeight(1) 等分;商品双列网格使用 Flex + FlexWrap.Wrap 自动换行;混合内容页面使用 Scroll + Column + ForEach;闪购双列布局也使用 Flex 换行方案。未使用 List 组件是因为页面内容量可控(最多 15 个商品),不需要懒加载优化。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// QQ潮流集市(购物)- 鸿蒙 ArkTS 完整示例
// 橙白电商风格 | 底部4Tab + 顶部6功能Tab | 4种弹框 | 柱状图 | 粒子特效
// ============================================================

// ---------------- 配色 interface + const ----------------
interface ColorPalette {
  primary: string
  primaryDark: string
  primaryLight: string
  bg: string
  cardBg: string
  ink: string
  sub: string
  faint: string
  red: string
  green: string
  gold: string
  border: string
  mask: string
}

const COLORS: ColorPalette = {
  primary: '#FF6B35',
  primaryDark: '#E85D04',
  primaryLight: '#FFE5D9',
  bg: '#FFF8F3',
  cardBg: '#FFFFFF',
  ink: '#1A1A2E',
  sub: '#7A7A8C',
  faint: '#B8B8C4',
  red: '#E63946',
  green: '#06A77D',
  gold: '#FFB627',
  border: '#F0E6DE',
  mask: 'rgba(26,26,46,0.55)'
}

// ---------------- 元信息 interface ----------------
interface ProductCategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface ProductStatusMeta {
  label: string
  color: string
  bg: string
  icon: string
}

interface KingKongEntry {
  label: string
  icon: string
  color: string
  bg: string
}

interface BannerInfo {
  title: string
  sub: string
  tag: string
  price: string
  bg: string
  icon: string
}

interface CouponMeta {
  title: string
  desc: string
  value: string
  color: string
  bg: string
  expireDate: string
}

interface ParticleDot {
  x: number
  y: number
  size: number
  color: string
  speed: number
  phase: number
  icon: string
}

interface BrandMeta {
  name: string
  slogan: string
  icon: string
  color: string
  bg: string
  fans: string
}

interface SpecOption {
  label: string
  color: string
  bg: string
}

interface AddressInfo {
  name: string
  phone: string
  region: string
  detail: string
  tag: string
}

interface TimeLineItem {
  time: string
  title: string
  desc: string
  icon: string
  color: string
}

interface RankMedal {
  label: string
  color: string
  bg: string
}

// ---------------- 数据模型 ----------------
@Observed
class ProductItem {
  id: number = 0
  name: string = ''
  category: string = ''
  icon: string = ''
  price: number = 0
  originalPrice: number = 0
  salesCount: number = 0
  stock: number = 0
  rating: number = 0
  reviewCount: number = 0
  status: string = '在售'
  tags: string[] = []
  desc: string = ''
  brand: string = ''
  shop: string = ''
  freeShipping: boolean = false
  coverColor: string = ''

  constructor(id: number, name: string, category: string, icon: string, price: number,
    originalPrice: number, salesCount: number, stock: number, rating: number,
    reviewCount: number, status: string, tags: string[], desc: string, brand: string,
    shop: string, freeShipping: boolean, coverColor: string) {
    this.id = id
    this.name = name
    this.category = category
    this.icon = icon
    this.price = price
    this.originalPrice = originalPrice
    this.salesCount = salesCount
    this.stock = stock
    this.rating = rating
    this.reviewCount = reviewCount
    this.status = status
    this.tags = tags
    this.desc = desc
    this.brand = brand
    this.shop = shop
    this.freeShipping = freeShipping
    this.coverColor = coverColor
  }
}

@Observed
class CartItem {
  id: number = 0
  productName: string = ''
  icon: string = ''
  price: number = 0
  quantity: number = 0
  spec: string = ''
  shop: string = ''
  selected: boolean = false
  coverColor: string = ''

  constructor(id: number, productName: string, icon: string, price: number,
    quantity: number, spec: string, shop: string, selected: boolean, coverColor: string) {
    this.id = id
    this.productName = productName
    this.icon = icon
    this.price = price
    this.quantity = quantity
    this.spec = spec
    this.shop = shop
    this.selected = selected
    this.coverColor = coverColor
  }
}

// ---------------- 配置 Record ----------------
const STATUS_MAP: Record<string, ProductStatusMeta> = {
  '在售': { label: '在售', color: '#06A77D', bg: 'rgba(6,167,125,0.12)', icon: '✓' },
  '预售': { label: '预售', color: '#FFB627', bg: 'rgba(255,182,39,0.15)', icon: '⏰' },
  '缺货': { label: '缺货', color: '#B8B8C4', bg: 'rgba(184,184,196,0.15)', icon: '✕' },
  '闪购': { label: '闪购', color: '#E63946', bg: 'rgba(230,57,70,0.12)', icon: '⚡' }
}

const CATEGORY_MAP: Record<string, ProductCategoryMeta> = {
  '数码': { label: '数码', icon: '📱', color: '#FF6B35', bg: '#FFE5D9' },
  '服饰': { label: '服饰', icon: '👕', color: '#E85D04', bg: '#FFEDE4' },
  '美妆': { label: '美妆', icon: '💄', color: '#E63946', bg: '#FDE3E5' },
  '家居': { label: '家居', icon: '🛋️', color: '#06A77D', bg: '#DDF3EC' },
  '食品': { label: '食品', icon: '🍪', color: '#FFB627', bg: '#FFF1D6' },
  '运动': { label: '运动', icon: '🏀', color: '#4361EE', bg: '#E2E7FD' }
}

const KINGKONG_ENTRIES: KingKongEntry[] = [
  { label: '限时秒杀', icon: '⚡', color: '#E63946', bg: '#FDE3E5' },
  { label: '品牌闪购', icon: '🏷️', color: '#E85D04', bg: '#FFEDE4' },
  { label: '领券中心', icon: '🎟️', color: '#FF6B35', bg: '#FFE5D9' },
  { label: '新品首发', icon: '✨', color: '#06A77D', bg: '#DDF3EC' },
  { label: '潮流榜单', icon: '🏆', color: '#FFB627', bg: '#FFF1D6' },
  { label: '橙心超市', icon: '🛒', color: '#4361EE', bg: '#E2E7FD' },
  { label: '会员专区', icon: '👑', color: '#9D4EDD', bg: '#F0E5FB' },
  { label: '直播好物', icon: '📺', color: '#FF6B35', bg: '#FFE5D9' }
]

const BANNERS: BannerInfo[] = [
  { title: '潮流数码节', sub: '爆款直降300元', tag: '数码专场', price: '1299', bg: '#FF6B35', icon: '🎧' },
  { title: '秋冬新风尚', sub: '大牌服饰5折起', tag: '服饰专场', price: '199', bg: '#E85D04', icon: '🧥' },
  { title: '美妆焕新季', sub: '买1享3超值套装', tag: '美妆专场', price: '99', bg: '#E63946', icon: '💄' }
]

const COUPONS: CouponMeta[] = [
  { title: '全品类通用券', desc: '全场商品可用,不可叠加', value: '50', color: '#FF6B35', bg: '#FFE5D9', expireDate: '2026.09.30' },
  { title: '数码专项券', desc: '数码频道专享满减', value: '100', color: '#E85D04', bg: '#FFEDE4', expireDate: '2026.09.15' },
  { title: '美妆焕新券', desc: '美妆频道专享满减', value: '30', color: '#E63946', bg: '#FDE3E5', expireDate: '2026.08.31' },
  { title: '新人专享券', desc: '仅限新注册用户领取', value: '20', color: '#06A77D', bg: '#DDF3EC', expireDate: '2026.12.31' },
  { title: '会员至尊券', desc: '黄金会员专享大额券', value: '200', color: '#FFB627', bg: '#FFF1D6', expireDate: '2026.10.20' }
]

const BRANDS: BrandMeta[] = [
  { name: '橙意数码', slogan: '年轻人数码首选', icon: '🍊', color: '#FF6B35', bg: '#FFE5D9', fans: '286万' },
  { name: '潮流前线', slogan: '潮流服饰风向标', icon: '👕', color: '#E85D04', bg: '#FFEDE4', fans: '532万' },
  { name: '花颜美妆', slogan: '国货美妆之光', icon: '🌸', color: '#E63946', bg: '#FDE3E5', fans: '198万' },
  { name: '橙居生活', slogan: '家居好物集合店', icon: '🛋️', color: '#06A77D', bg: '#DDF3EC', fans: '124万' },
  { name: '跃动体育', slogan: '专业运动装备', icon: '🏀', color: '#4361EE', bg: '#E2E7FD', fans: '167万' },
  { name: '零食大亨', slogan: '全球零食搬运工', icon: '🍪', color: '#FFB627', bg: '#FFF1D6', fans: '403万' }
]

const SPEC_OPTIONS: SpecOption[] = [
  { label: '官方标配', color: '#FF6B35', bg: '#FFE5D9' },
  { label: '豪华套装', color: '#FF6B35', bg: '#FFE5D9' },
  { label: '旗舰顶配', color: '#FF6B35', bg: '#FFE5D9' }
]

const TIMELINE_ITEMS: TimeLineItem[] = [
  { time: '08-23 10:00', title: '超感知降噪耳机 Pro3', desc: '预约立减100,前100名送保护套', icon: '🎧', color: '#FF6B35' },
  { time: '08-23 14:00', title: '轻氧焕肤精华礼盒', desc: '新品首发买1享3,限量5000套', icon: '🧴', color: '#E63946' },
  { time: '08-24 09:00', title: '城市通勤折叠单车', desc: '首发价直降400,晒单返20元', icon: '🚲', color: '#06A77D' },
  { time: '08-25 20:00', title: '智能扫地机器人 X9', desc: '激光导航,预约赠耗材大礼包', icon: '🤖', color: '#4361EE' },
  { time: '08-26 12:00', title: '手工ins风陶瓷餐盘', desc: '设计师联名款,限量发售', icon: '🍽️', color: '#FFB627' }
]

const RANK_MEDALS: RankMedal[] = [
  { label: '1', color: '#FFFFFF', bg: '#FFB627' },
  { label: '2', color: '#FFFFFF', bg: '#FF6B35' },
  { label: '3', color: '#FFFFFF', bg: '#E85D04' }
]

const SHOP_TAB_LABELS: string[] = ['精选', '闪购', '品牌', '券', '新品', '排行']

const BOTTOM_TAB_META: KingKongEntry[] = [
  { label: '首页', icon: '🏠', color: '#FF6B35', bg: '#FFF8F3' },
  { label: '分类', icon: '🗂️', color: '#FF6B35', bg: '#FFF8F3' },
  { label: '购物车', icon: '🛒', color: '#FF6B35', bg: '#FFF8F3' },
  { label: '我的', icon: '👤', color: '#FF6B35', bg: '#FFF8F3' }
]

// ---------------- 静态商品数据(15个) ----------------
const PRODUCT_LIST: ProductItem[] = [
  new ProductItem(1, '超感知降噪耳机 Pro3', '数码', '🎧', 1299, 1599, 23850, 326, 4.9, 18234, '闪购',
    ['爆款', '降噪', '40h续航'], '旗舰级主动降噪,Hi-Res双认证音质,单次充电畅听40小时', '橙意数码', '橙意数码旗舰店', true, '#FFE5D9'),
  new ProductItem(2, '轻氧焕肤精华礼盒', '美妆', '🧴', 199, 399, 45210, 880, 4.8, 30215, '在售',
    ['买1享3', '国货', '敏感肌可用'], '烟酰胺+玻色因双效配方,28天焕亮肤色,礼盒装送人自用两相宜', '花颜美妆', '花颜官方自营店', true, '#FDE3E5'),
  new ProductItem(3, '城市通勤折叠单车', '运动', '🚲', 1599, 2199, 8930, 45, 4.7, 6210, '在售',
    ['轻量', '快拆', '25km续航'], '航空铝车架仅12.8kg,三秒折叠,通勤地铁无缝衔接', '跃动体育', '跃动体育专卖店', false, '#E2E7FD'),
  new ProductItem(4, '智能扫地机器人 X9', '家居', '🤖', 2899, 3499, 15620, 120, 4.9, 11890, '在售',
    ['激光导航', '自动集尘', '拖扫一体'], 'LDS激光导航+AI避障,5200Pa大吸力,基站自动集尘清洗', '橙居生活', '橙居生活旗舰店', true, '#DDF3EC'),
  new ProductItem(5, 'oversize落日卫衣', '服饰', '👕', 139, 259, 62300, 560, 4.6, 45120, '闪购',
    ['潮流', '加绒', '三色可选'], '450g重磅纯棉,落日渐变印花,oversize慵懒版型', '潮流前线', '潮流前线旗舰店', true, '#FFEDE4'),
  new ProductItem(6, '手冲咖啡大师套装', '家居', '☕', 269, 429, 9870, 210, 4.8, 7643, '在售',
    ['入门优选', '全套', '礼盒装'], '含手冲壶+滤杯+磨豆机+分享壶,新手一键进阶手冲大师', '橙居生活', '橙居生活好物店', true, '#FFF1D6'),
  new ProductItem(7, '全屋香薰蜡烛礼盒', '家居', '🕯️', 99, 169, 31500, 0, 4.7, 22310, '缺货',
    ['大豆蜡', '4味套装', '治愈系'], '四种治愈香型,天然大豆蜡48小时持久香氛', '橙居生活', '橙居生活选品店', true, '#FFF1D6'),
  new ProductItem(8, '电竞机械键盘 84键', '数码', '⌨️', 329, 459, 18760, 334, 4.8, 14560, '在售',
    ['客制化', '热插拔', 'RGB'], '全键热插拔+Gasket结构,三模连接,出厂润轴顺滑无比', '橙意数码', '橙意数码装备铺', true, '#FFE5D9'),
  new ProductItem(9, '轻奢小方包链条款', '服饰', '👜', 359, 699, 26890, 78, 4.7, 19870, '预售',
    ['轻奢', '五金链条', '两用'], '头层牛皮+复古五金,手提斜挎两用,通勤约会百搭', '潮流前线', '潮流箱包专营店', false, '#FFEDE4'),
  new ProductItem(10, '每日坚果科学配比装', '食品', '🥜', 89, 139, 88200, 1500, 4.9, 60120, '在售',
    ['30包/箱', '无添加', '新鲜锁鲜'], '六种坚果果干科学配比,独立小包锁鲜,每日一包营养均衡', '零食大亨', '零食大亨自营店', true, '#FFF1D6'),
  new ProductItem(11, '碎花法式茶歇裙', '服饰', '👗', 229, 399, 35600, 230, 4.6, 27650, '在售',
    ['法式', '显瘦', '雪纺'], '垂坠雪纺面料,收腰显瘦剪裁,度假约会一条搞定', '潮流前线', '潮流女装甄选店', true, '#FFEDE4'),
  new ProductItem(12, '便携折叠电风扇', '数码', '🌀', 69, 119, 54100, 890, 4.5, 38900, '闪购',
    ['折叠', '4档风力', 'Type-C'], '巴掌大小折叠收纳,4档风力续航16小时,宿舍办公神器', '橙意数码', '橙意小电器店', true, '#FFE5D9'),
  new ProductItem(13, '哑光丝绒口红全套', '美妆', '💄', 169, 299, 47800, 66, 4.8, 35410, '在售',
    ['6色全套', '不沾杯', '显白'], '丝绒哑光质地,轻薄不拔干,六支热门色号一盒集齐', '花颜美妆', '花颜美妆旗舰店', true, '#FDE3E5'),
  new ProductItem(14, '智能体脂秤S2', '运动', '⚖️', 99, 179, 28900, 440, 4.7, 20130, '在售',
    ['26项指标', 'APP同步', '充电款'], '高精度BIA生物电阻抗,26项身体数据,趋势图表同步手机', '跃动体育', '跃动智能硬件店', true, '#E2E7FD'),
  new ProductItem(15, '流心巧克力礼盒', '食品', '🍫', 79, 128, 39400, 320, 4.8, 25600, '预售',
    ['低糖', '18枚', '冰袋配送'], '比利时进口可可,低糖配方,夹心一咬爆浆,冰袋锁鲜配送', '零食大亨', '零食大亨甜品铺', true, '#FFF1D6')
]

// ---------------- 静态购物车数据(8个) ----------------
const INIT_CART: CartItem[] = [
  new CartItem(1, '超感知降噪耳机 Pro3', '🎧', 1299, 1, '官方标配', '橙意数码旗舰店', true, '#FFE5D9'),
  new CartItem(2, '轻氧焕肤精华礼盒', '🧴', 199, 2, '买1享3礼盒', '花颜官方自营店', true, '#FDE3E5'),
  new CartItem(3, 'oversize落日卫衣', '👕', 139, 1, 'L / 落日橘', '潮流前线旗舰店', false, '#FFEDE4'),
  new CartItem(4, '每日坚果科学配比装', '🥜', 89, 3, '30包/箱', '零食大亨自营店', true, '#FFF1D6'),
  new CartItem(5, '电竞机械键盘 84键', '⌨️', 329, 1, '白灰轴 / 三模', '橙意数码装备铺', false, '#FFE5D9'),
  new CartItem(6, '智能扫地机器人 X9', '🤖', 2899, 1, '白 / 基站版', '橙居生活旗舰店', true, '#DDF3EC'),
  new CartItem(7, '便携折叠电风扇', '🌀', 69, 2, '奶油白', '橙意小电器店', false, '#FFE5D9'),
  new CartItem(8, '碎花法式茶歇裙', '👗', 229, 1, 'M / 碎花蓝', '潮流女装甄选店', true, '#FFEDE4')
]

// ---------------- 纯函数工具 ----------------
function formatPrice(value: number): string {
  return value.toFixed(2)
}

function discountPercent(price: number, original: number): number {
  if (original <= 0) {
    return 100
  }
  return Math.round(price / original * 100) / 10
}

function formatSales(count: number): string {
  if (count >= 10000) {
    return (count / 10000).toFixed(1) + '万'
  }
  return count.toString()
}

// ---------------- 粒子初始化/更新 ----------------
const PARTICLE_ICONS: string[] = ['🎟️', '🛍️', '🏷️', '💰', '🎁', '🧧']

function buildParticles(): ParticleDot[] {
  const dots: ParticleDot[] = []
  for (let i = 0; i < 16; i++) {
    dots.push({
      x: 6 + (i * 5.8) % 88,
      y: 10 + (i * 37) % 80,
      size: 14 + (i % 4) * 6,
      color: i % 2 === 0 ? '#FF6B35' : '#FFB627',
      speed: 0.4 + (i % 5) * 0.15,
      phase: i * 0.7,
      icon: PARTICLE_ICONS[i % PARTICLE_ICONS.length]
    })
  }
  return dots
}

function advanceParticles(dots: ParticleDot[]): ParticleDot[] {
  const next: ParticleDot[] = []
  for (let i = 0; i < dots.length; i++) {
    const d: ParticleDot = dots[i]
    const y: number = d.y - d.speed
    next.push({
      x: d.x + Math.sin(d.phase + y * 0.12) * 0.6,
      y: y < -8 ? 102 : y,
      size: d.size,
      color: d.color,
      speed: d.speed,
      phase: d.phase,
      icon: d.icon
    })
  }
  return next
}

// ---------------- Tab 枚举 ----------------
enum ShopTab {
  FEATURED = 0,
  FLASH = 1,
  BRAND = 2,
  COUPON = 3,
  NEW = 4,
  RANK = 5
}

enum BottomTab {
  HOME = 0,
  CATEGORY = 1,
  CART = 2,
  MINE = 3
}

function cloneCart(): CartItem[] {
  const list: CartItem[] = []
  for (let i = 0; i < INIT_CART.length; i++) {
    const c: CartItem = INIT_CART[i]
    list.push(new CartItem(c.id, c.productName, c.icon, c.price, c.quantity,
      c.spec, c.shop, c.selected, c.coverColor))
  }
  return list
}

function sortedBySales(): ProductItem[] {
  const list: ProductItem[] = PRODUCT_LIST.slice()
  list.sort((a: ProductItem, b: ProductItem) => b.salesCount - a.salesCount)
  return list
}

// ---------------- 主页面 ----------------
@Entry
@Component
struct MarketPage {
  @State currentBottomTab: number = BottomTab.HOME
  @State currentShopTab: number = ShopTab.FEATURED
  @State cartItems: CartItem[] = cloneCart()
  @State showCartModal: boolean = false
  @State showAddressModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedProduct: ProductItem = PRODUCT_LIST[0]
  @State deleteTargetId: number = 0
  @State selectedSpec: number = 0
  @State cartQuantity: number = 1
  @State searchKey: string = ''
  @State messageCount: number = 6
  @State flashSeconds: number = 4 * 3600 + 25 * 60 + 18
  @State detailSlideIndex: number = 0
  @State particles: ParticleDot[] = buildParticles()
  @State address: AddressInfo = {
    name: '陈橙子',
    phone: '138****6688',
    region: '广东省 深圳市 南山区',
    detail: '科技园南区深南大道9988号潮流大厦A座2201室',
    tag: '公司'
  }
  @State addrName: string = '陈橙子'
  @State addrPhone: string = '138****6688'
  @State addrRegion: string = '广东省 深圳市 南山区'
  @State addrDetail: string = '科技园南区深南大道9988号潮流大厦A座2201室'
  @State addrTag: string = '公司'
  @State couponTaken: boolean[] = [false, false, false, false, false]
  particleTimer: number = -1
  flashTimer: number = -1
  rankList: ProductItem[] = sortedBySales()

  aboutToAppear(): void {
    this.particleTimer = setInterval(() => {
      this.particles = advanceParticles(this.particles)
    }, 60)
    this.flashTimer = setInterval(() => {
      if (this.flashSeconds > 0) {
        this.flashSeconds = this.flashSeconds - 1
      } else {
        this.flashSeconds = 6 * 3600
      }
    }, 1000)
  }

  aboutToDisappear(): void {
    if (this.particleTimer >= 0) {
      clearInterval(this.particleTimer)
    }
    if (this.flashTimer >= 0) {
      clearInterval(this.flashTimer)
    }
  }

  // ---------------- 计算方法 ----------------
  cartCount(): number {
    let total: number = 0
    for (let i = 0; i < this.cartItems.length; i++) {
      total += this.cartItems[i].quantity
    }
    return total
  }

  cartSelectedCount(): number {
    let total: number = 0
    for (let i = 0; i < this.cartItems.length; i++) {
      if (this.cartItems[i].selected) {
        total += this.cartItems[i].quantity
      }
    }
    return total
  }

  cartSelectedTotal(): number {
    let total: number = 0
    for (let i = 0; i < this.cartItems.length; i++) {
      if (this.cartItems[i].selected) {
        total += this.cartItems[i].quantity * this.cartItems[i].price
      }
    }
    return total
  }

  isAllSelected(): boolean {
    if (this.cartItems.length === 0) {
      return false
    }
    for (let i = 0; i < this.cartItems.length; i++) {
      if (!this.cartItems[i].selected) {
        return false
      }
    }
    return true
  }

  toggleSelectAll(): void {
    const target: boolean = !this.isAllSelected()
    for (let i = 0; i < this.cartItems.length; i++) {
      this.cartItems[i].selected = target
    }
    this.cartItems = this.cartItems.slice()
  }

  toggleSelectItem(id: number): void {
    for (let i = 0; i < this.cartItems.length; i++) {
      if (this.cartItems[i].id === id) {
        this.cartItems[i].selected = !this.cartItems[i].selected
      }
    }
    this.cartItems = this.cartItems.slice()
  }

  changeQuantity(id: number, delta: number): void {
    for (let i = 0; i < this.cartItems.length; i++) {
      if (this.cartItems[i].id === id) {
        const next: number = this.cartItems[i].quantity + delta
        if (next >= 1) {
          this.cartItems[i].quantity = next
        }
      }
    }
    this.cartItems = this.cartItems.slice()
  }

  confirmDelete(): void {
    const remain: CartItem[] = []
    for (let i = 0; i < this.cartItems.length; i++) {
      if (this.cartItems[i].id !== this.deleteTargetId) {
        remain.push(this.cartItems[i])
      }
    }
    this.cartItems = remain
    this.showDeleteModal = false
  }

  addToCart(product: ProductItem): void {
    const exist: CartItem[] = this.cartItems.filter((c: CartItem) => c.id === product.id)
    if (exist.length > 0) {
      for (let i = 0; i < this.cartItems.length; i++) {
    
          }, (menu: KingKongEntry) => menu.label)
        }
        .width('100%')
        .padding({ left: 14, right: 14 })
        .borderRadius(14)
        .backgroundColor(COLORS.cardBg)
        .margin({ left: 12, right: 12, top: 12 })

        Text('QQ潮流集市 · 橙心橙意为你服务')
          .fontSize(11)
          .fontColor(COLORS.faint)
          .margin({ top: 24, bottom: 24 })
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .width('100%')
  }
}

const ORDER_STATUSES: ProductStatusMeta[] = [
  { label: '待付款', color: '', bg: '', icon: '💳' },
  { label: '待发货', color: '', bg: '', icon: '📦' },
  { label: '待收货', color: '', bg: '', icon: '🚚' },
  { label: '待评价', color: '', bg: '', icon: '✍️' },
  { label: '退款/售后', color: '', bg: '', icon: '🔄' }
]

const MINE_MENU: KingKongEntry[] = [
  { label: '我的订单', icon: '🧾', color: '', bg: '' },
  { label: '优惠券包', icon: '🎟️', color: '', bg: '' },
  { label: '收藏夹', icon: '❤️', color: '', bg: '' },
  { label: '浏览足迹', icon: '👣', color: '', bg: '' },
  { label: '客服中心', icon: '💬', color: '', bg: '' },
  { label: '设置', icon: '⚙️', color: '', bg: '' }
]







在这里插入图片描述

五、技术总结

本文通过逐段拆解约 3500 行源代码,完整分析了基于 HarmonyOS 6.1.1 和 HarmonyOS ArkTS API 24 的电商购物应用"QQ潮流集市"的实现架构与技术细节。该应用采用橙白电商风格设计语言,通过 ColorPalette 接口和 COLORS 常量构建了统一的全局配色系统,确保了 20+ 个组件的视觉一致性。应用架构以 MarketPage 为入口,通过 @State 管理的 22 个状态变量驱动底部 4 Tab 路由、顶部 6 功能 Tab 切换和 4 种弹框的互斥渲染,形成清晰的三层路由结构。

在数据模型层面,@Observed 装饰的 ProductItemCartItem 类实现了属性级别的变化追踪,配合 this.cartItems = this.cartItems.slice() 的引用变更技巧,确保了购物车操作(选中、加减、删除、添加)的 UI 实时同步。粒子特效系统通过 setInterval 每 60ms 调用纯函数 advanceParticles 计算下一帧位置,16 个粒子以正弦轨迹飘浮并循环复用,在保证视觉效果的同时控制了渲染开销。闪购倒计时通过 1000ms 定时器驱动 flashSeconds 递减,经 pad2 格式化后在 FlashContent 组件中展示为 HH:MM:SS 格式。

柱状图可视化通过 barHeight 方法将销量归一化映射到 8-130 像素高度区间,配合 barColor 的四色循环调色板实现彩色柱状图效果。弹框系统采用条件渲染方案,通过空函数体 onClick 阻止事件冒泡,确保用户在弹框内部的交互不会意外触发遮罩层的关闭逻辑。组件间通信采用"状态提升 + 回调下传"模式,所有子组件通过普通成员属性接收数据和回调函数,依赖父组件重渲染刷新,避免了 @Prop/@Link 的额外复杂度。

Logo

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

更多推荐