基于HarmonyOS API 24的ArkTS电商实战:QQ潮流集市购物App全栈解析——HarmonyOS 6.1.1与HarmonyOS ArkTS API 24深度应用
技术引言
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 向下分发的三层路由结构:底部 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 渲染的全链路类型安全。
值得注意的是这些接口的粒度划分策略。ProductCategoryMeta 和 ProductStatusMeta 分别描述商品分类和状态的元信息,它们将显示文字、图标、前景色、背景色打包为一个整体,使得在 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_MAP 和 CATEGORY_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_LABELS 和 BOTTOM_TAB_META 分别定义了顶部 6 功能 Tab 和底部 4 导航 Tab 的标签文字和图标,底部 Tab 的 color 和 bg 字段统一使用主色系,保持品牌一致性。
代码段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
}
ShopTab 和 BottomTab 两个枚举定义了 Tab 的索引常量。使用枚举而非魔法数字(如直接用 0、1、2)使得代码可读性大幅提升——currentBottomTab === BottomTab.CART 比 currentBottomTab === 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() 通过索引安全获取规格名称,包含边界检查和默认值返回。
三、组件生命周期与数据流
上图展示了从应用启动到销毁的完整数据流时序。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 切换。第二组是弹框控制状态:四个布尔变量(showCartModal、showAddressModal、showDeleteModal、showDetailModal)分别控制四种弹框的显隐,通过互斥条件确保同一时刻只有一个弹框可见。第三组是业务数据状态:cartItems 持有购物车数据(通过 cloneCart() 初始化为独立副本),selectedProduct 持有当前选中商品,address 持有收货地址,couponTaken 以布尔数组记录优惠券领取状态。第四组是临时交互状态:selectedSpec 和 cartQuantity 在加入购物车弹框中使用,deleteTargetId 在删除确认弹框中使用,flashSeconds 存储闪购倒计时秒数,particles 存储粒子数组。
值得注意的设计细节是地址编辑的双缓冲机制。address 是正式地址数据,而 addrName、addrPhone、addrRegion、addrDetail、addrTag 是编辑过程中的临时缓冲。当用户打开地址编辑弹框时,正式数据被复制到缓冲变量;用户在弹框中修改的是缓冲变量;点击"保存地址"时,缓冲变量的值才被提交到 address。这种模式确保了用户取消编辑时不会污染正式数据。另外,particleTimer 和 flashTimer 不使用 @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)
}
}
aboutToAppear 和 aboutToDisappear 是 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 循环而非 reduce 或 filter 等 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 检查商品是否已在购物车中,如果已存在则增加数量并设为选中,如果不存在则创建新的 CartItem 并 push 进数组。
所有方法末尾都有一个关键操作:this.cartItems = this.cartItems.slice()。这是 ArkTS 状态更新的核心技巧。在 HarmonyOS ArkTS API 24 中,@State 修饰的数组对元素属性的修改不会自动触发 UI 更新——因为数组的引用地址没有变化。通过 slice() 创建一个新数组(浅拷贝)并重新赋值,@State 检测到引用变化后触发重渲染。addToCart() 方法在操作完成后重置了 cartQuantity 和 selectedSpec,确保下次打开弹框时这些临时状态回到初始值,避免残留状态影响用户体验。
代码段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 更新的完整链路。
上图清晰呈现了购物车五大操作的并行与串行关系。所有数据更新操作最终汇聚到 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 路由。当 currentBottomTab 为 HOME 时,渲染首页结构(包含 homeHeader、shopTabBar 和功能 Tab 内容组件);为 CART 时渲染 CartContent;其他 Tab 类似。在首页内部,进一步通过 if-else if 链实现顶部功能 Tab 路由,根据 currentShopTab 的值渲染不同的内容组件。子组件通过参数传递接收数据和回调函数。回调函数采用箭头函数形式,确保 this 绑定到 MarketPage 实例。例如 onAddCart 回调在子组件中被调用时,会修改 MarketPage 的 selectedProduct 和 showCartModal 状态,从而触发弹框显示。这种"状态提升 + 回调下传"的模式是 ArkTS 组件间通信的标准范式。CouponContent 的 onTakeCoupon 回调展示了不可变更新模式:先 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 属性接受 angle 和 colors 数组,从左上 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.CART 且 cartCount() > 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 输入框或选择器。TextInput 的 backgroundColor 设为 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 个商品,以左右布局的卡片形式展示,包含图标、名称、描述、状态标签、价格和销量信息。组件通过 onAddCart 和 onOpenDetail 两个回调函数与父组件通信。回调函数的默认值是空箭头函数,确保即使父组件未传入回调也不会运行时报错。这是 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 组件接收三个时间文本参数(flashHourText、flashMinuteText、flashSecondText),这些参数由父组件 MarketPage 的 flashHour()、flashMinute()、flashSecond() 方法计算后传入。这种设计将倒计时逻辑集中在父组件中,子组件仅负责展示,符合"状态提升"原则。@Builder flashTimeBox 是一个局部 UI 构建器,用于渲染倒计时数字方块——深色背景(COLORS.ink)的圆角矩形,白色粗体数字居中。通过调用 this.flashTimeBox(this.flashHourText) 复用该构建器三次,分别渲染时、分、秒。
闪购头部区域使用红橙渐变(COLORS.red 到 COLORS.primaryDark),传达限时抢购的紧迫感。商品列表使用 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 实现两列自动换行布局。每个商品卡片固定宽度 160px,Flex 的 SpaceBetween 对齐使得两列卡片自动分布到屏幕两侧,中间留出间距。卡片顶部使用 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 标记可变数据模型(ProductItem 和 CartItem)。未使用 @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 装饰的 ProductItem 和 CartItem 类实现了属性级别的变化追踪,配合 this.cartItems = this.cartItems.slice() 的引用变更技巧,确保了购物车操作(选中、加减、删除、添加)的 UI 实时同步。粒子特效系统通过 setInterval 每 60ms 调用纯函数 advanceParticles 计算下一帧位置,16 个粒子以正弦轨迹飘浮并循环复用,在保证视觉效果的同时控制了渲染开销。闪购倒计时通过 1000ms 定时器驱动 flashSeconds 递减,经 pad2 格式化后在 FlashContent 组件中展示为 HH:MM:SS 格式。
柱状图可视化通过 barHeight 方法将销量归一化映射到 8-130 像素高度区间,配合 barColor 的四色循环调色板实现彩色柱状图效果。弹框系统采用条件渲染方案,通过空函数体 onClick 阻止事件冒泡,确保用户在弹框内部的交互不会意外触发遮罩层的关闭逻辑。组件间通信采用"状态提升 + 回调下传"模式,所有子组件通过普通成员属性接收数据和回调函数,依赖父组件重渲染刷新,避免了 @Prop/@Link 的额外复杂度。
更多推荐


所有评论(0)