HarmonyOS ArkTS API 24在线商城商品管理应用自定义模态弹窗的 Stack 叠加实现——使用 Stack 容器将遮罩层与内容层堆叠,通过 visible 状态变量控制显隐
引言:从应用背景到技术栈的全景俯瞰
随着鸿蒙生态(HarmonyOS)的快速扩张与 HarmonyOS NEXT 的全面推送,基于 ArkTS 声明式范式构建的原生应用正逐步成为终端开发者在新一代分布式操作系统上的首选方案。ArkTS 在 TypeScript 的基础上做了进一步收敛与增强:它保留了类型系统带来的编译期安全保障,同时引入了 @Entry、@Component、@State、@Prop、@Observed、@Builder 等装饰器语法,将"状态驱动 UI"的理念贯彻到语言的语法层面,使开发者能够以一种更接近自然描述的方式书写界面逻辑。这种设计思路既不同于传统 Android 的命令式 View 体系,也有别于 Flutter 的"一切皆 Widget"的纯函数式构建——ArkTS 选择了一条"组件化 + 状态可观察 + 单向数据流"的中间路线,在表达力与性能之间取得了精妙的平衡。

本文要剖析的是一个典型的在线商城商品管理(E-Commerce Product Manager)应用。它并非一个简单的"Hello World"示例,而是一个具备完整业务闭环的中型示例工程:从商品列表的展示、购物车的增删与结算、订单状态的追踪,到收藏夹的维护、个人中心的统计概览,再到商品的新增、编辑、删除等后台管理操作,几乎涵盖了一个电商类应用最核心的交互场景。在视觉层面,应用采用了一套以 Teal(青绿色,主色 #00695C)为基调的设计语言,辅以浅青底色 #E0F2F1 与白色卡片 #FFFFFF,整体观感清新、层次分明,符合 Material Design 风格下"以卡片为信息单元"的组织方式;在交互层面,通过底部 TabBar 切换五个一级页面,并通过自定义的模态弹窗(Modal)承载表单录入与危险操作确认,形成"主页面 + 浮层"的双层信息架构。
从技术栈角度审视,本应用集中体现了 ArkTS 的若干关键能力:其一,接口(interface)驱动的数据建模——通过定义 ProductConfig、CartItemConfig 等纯类型契约,将数据形状与业务逻辑解耦,保证组件入参的类型安全;其二,@Observed 装饰器与可观察数据类——为复杂业务对象建立响应式包装,使深层属性变化也能被框架捕获;其三,@State / @Prop 的状态分层管理——父组件持有可变状态、子组件通过 @Prop 接收只读快照,形成清晰的数据流向;其四,@Builder 构建器方法——将重复的 UI 片段抽取为可复用的构建逻辑,在不引入额外组件层级的前提下提升代码复用率;其五,条件渲染与列表渲染的组合——通过 if/else 控制空状态与列表状态的切换,并采用一种"手动展开 + 边界检查"的独特列表渲染策略;其六,自定义模态弹窗的 Stack 叠加实现——使用 Stack 容器将遮罩层与内容层堆叠,通过 visible 状态变量控制显隐,模拟原生对话框行为。这些技术点共同构成了一个"麻雀虽小、五脏俱全"的 ArkTS 工程范本,非常值得逐段拆解学习。
下面我们将按照源码的自然组织顺序,从接口定义、数据类、常量与枚举、工具函数、静态数据,到各个自定义组件、模态弹窗,再到主入口组件的状态管理与构建器方法,最后到整体布局装配,逐段给出代码片段并进行详尽剖析。
一、数据契约层:interface 接口定义
代码片段
interface ProductConfig {
id: number;
name: string;
price: number;
category: string;
stock: number;
rating: number;
emoji: string;
}
interface CartItemConfig {
id: number;
productId: number;
name: string;
price: number;
quantity: number;
emoji: string;
}
interface OrderConfig {
id: number;
productName: string;
amount: number;
status: string;
date: string;
emoji: string;
}
interface FavoriteConfig {
id: number;
productId: number;
name: string;
price: number;
emoji: string;
}
interface StatConfig {
category: string;
sales: number;
color: string;
}
interface BarItemConfig {
label: string;
value: number;
maxValue: number;
color: string;
}
interface TabItemConfig {
label: string;
icon: string;
index: number;
}

逐段解析
文件的开篇并非直接进入 UI 构建,而是先以一连串 interface 声明搭建起整个应用的"数据骨架"。这是 ArkTS 工程的一种良好实践:先建模、后视图。在声明式 UI 框架中,UI 本质上是"状态的函数投影",而状态的形状如果事先没有被类型系统约束,后续在组件间传递时极易出现拼写错误、字段缺失等运行时才暴露的问题。通过 interface 提前固化字段名与类型,编译器可以在编码阶段就拦截大量低级错误。
具体来看,七个接口各自承担明确的语义职责。ProductConfig 描述一个商品实体,包含主键 id、名称 name、价格 price、类目 category、库存 stock、评分 rating 以及表情符号 emoji——这里用 emoji 充当商品缩略图,是一种在示例工程中常见的"轻量视觉占位"技巧,既避免了引入图片资源带来的体积负担,又能让界面具备一定的视觉辨识度。CartItemConfig 描述购物车条目,相比商品多出 productId(指向原商品)与 quantity(购买数量)两个字段,体现了"购物车项是商品的衍生实体"这一业务关系。OrderConfig 描述订单,字段命名从 name 变为 productName、从 price 变为 amount,这种命名差异并非随意,而是反映了订单作为"已成交快照"的语义——它记录的是下单那一刻的金额,而非商品的当前售价。FavoriteConfig 描述收藏项,是商品的轻量引用,仅保留展示所需的最小字段集。StatConfig 与 BarItemConfig 服务于统计图表,前者记录"类目—销售额—颜色"三元组,后者则是更通用的条形图数据单元(虽然在本应用中 BarItemConfig 实际未被使用,可视为预留的扩展点)。TabItemConfig 描述底部导航项,包含标签、图标与索引。
值得注意的是,所有接口均采用纯字段声明、不带方法。这是 ArkTS 中数据契约的典型形态:interface 只描述"是什么",不描述"怎么做",行为逻辑交由组件或工具函数承担。这种"贫血模型"在声明式 UI 中是合理的选择,因为状态与行为的耦合反而会降低组件的可测试性与可复用性。此外,id 字段在多个接口中重复出现,但语义不同:在 ProductConfig 中是商品主键,在 CartItemConfig 中是购物车条目主键,在 FavoriteConfig 中是收藏记录主键——这种"每张表自增主键"的设计模拟了真实后端数据库的表结构,便于后续替换为真实接口时直接映射。
二、可观察数据类:@Observed 装饰器
代码片段
@Observed
class ProductModel {
id: number;
name: string;
price: number;
category: string;
stock: number;
rating: number;
emoji: string;
constructor(id: number, name: string, price: number, category: string,
stock: number, rating: number, emoji: string) {
this.id = id;
this.name = name;
this.price = price;
this.category = category;
this.stock = stock;
this.rating = rating;
this.emoji = emoji;
}
}
@Observed
class CartModel {
id: number;
productId: number;
name: string;
price: number;
quantity: number;
emoji: string;
constructor(id: number, productId: number, name: string, price: number,
quantity: number, emoji: string) {
this.id = id;
this.productId = productId;
this.name = name;
this.price = price;
this.quantity = quantity;
this.emoji = emoji;
}
}

逐段解析
在 interface 之上,源码又定义了两个被 @Observed 装饰的类:ProductModel 与 CartModel。这里需要厘清 ArkTS 中三种数据载体的区别与分工:interface 是纯类型契约,编译后被擦除;普通 class 是运行时存在的对象但默认不具备响应式能力;@Observed class 则是框架增强后的可观察对象,其属性变更会被框架劫持并通知到依赖它的 UI 组件。
@Observed 的核心价值在于处理"嵌套对象的深层属性变更"。在 ArkTS 的状态管理体系中,@State 只能感知到"变量本身的赋值"(即引用替换),而无法感知到"对象内部某个字段的修改"。例如,若有一个 @State product: ProductConfig,执行 this.product = newProduct 会触发刷新,但执行 this.product.stock = 0 则不会——因为 @State 监听的是引用而非内容。此时若将 ProductConfig 升级为 @Observed 的 ProductModel,框架会在其属性被赋值时主动发出变更通知,配合子组件中的 @ObjectLink 即可实现深层响应。本例中虽然主组件用的是 @State 数组而非 @ObjectLink,但定义 @Observed 类是一种"为未来留口"的设计——一旦后续需要做商品库存的实时编辑、购物车数量的步进调整,这些类已经具备响应式基础,无需重构数据层。
两个类都显式声明了 constructor,逐字段赋值。这种写法虽然比 TypeScript 的"参数属性"(constructor(public id: number, ...))更冗长,但在 ArkTS 中是必要的——ArkTS 对类语法的约束更严格,参数属性等语法糖并不被完整支持,显式赋值既保证可读性也保证兼容性。字段顺序与对应 interface 保持一致,命名也完全对应,使开发者能够在"接口契约"与"可观察实现"之间自由切换:在只需要类型约束的场合(如组件 @Prop 入参)使用 interface,在需要响应式行为的场合使用 @Observed class。这种"接口与实现并行"的双轨设计,体现了 ArkTS 在类型系统上的灵活性。
三、视觉常量与导航枚举
代码片段
const PRIMARY_COLOR: string = '#00695C';
const PRIMARY_LIGHT: string = '#00897B';
const PRIMARY_DARK: string = '#004D40';
const ACCENT_COLOR: string = '#26A69A';
const BG_COLOR: string = '#E0F2F1';
const CARD_BG: string = '#FFFFFF';
const TEXT_PRIMARY: string = '#212121';
const TEXT_SECONDARY: string = '#757575';
const DANGER_COLOR: string = '#D32F2F';
const SUCCESS_COLOR: string = '#388E3C';
const WARNING_COLOR: string = '#F9A825';
enum ShopTab {
ALL = 0,
CART = 1,
ORDERS = 2,
FAVORITES = 3,
PROFILE = 4
}

逐段解析
颜色常量集中定义在文件顶层,是工程化 UI 开发的标准动作。这组常量以 PRIMARY_COLOR(主色,深青 #00695C)为核心,向外延伸出 PRIMARY_LIGHT(亮主色 #00897B)、PRIMARY_DARK(暗主色 #004D40)、ACCENT_COLOR(强调色 #26A69A)四个同色系阶梯,构成了应用的主色调梯度。背景层用 BG_COLOR(极浅青 #E0F2F1)与 CARD_BG(纯白 #FFFFFF)形成"浅底白卡"的层次对比,是 Material Design 卡片式布局的经典配色。文本层用 TEXT_PRIMARY(近黑 #212121)与 TEXT_SECONDARY(中灰 #757575)区分主次信息,符合 WCAG 对比度建议。功能色方面,DANGER_COLOR(红 #D32F2F)用于价格与删除按钮,SUCCESS_COLOR(绿 #388E3C)用于"已签收"“充足"等正向状态,WARNING_COLOR(橙黄 #F9A825)用于"运输中”"库存紧张"等中性偏警示状态——这套语义化色彩体系让用户无需阅读文字即可凭颜色感知信息类别。
将这些颜色提取为常量而非散落在各处硬编码,带来三重收益:一是统一性,全应用同一语义的颜色始终保持一致,避免视觉碎片化;二是可维护性,若要做暗色模式适配或主题切换,只需修改这一处常量定义;三是可读性,代码中出现 DANGER_COLOR 比出现 '#D32F2F' 更易理解其业务含义。值得注意的细节是,常量类型显式标注为 string,这在 ArkTS 中是好习惯——ArkTS 比 TypeScript 更强调显式类型,省略类型注解虽然语法允许,但显式标注能让意图更清晰、也能避免某些推断歧义。
ShopTab 枚举定义了五个底部导航项的索引值,从 0 到 4 依次对应"全部商品"“购物车”“订单”“收藏”“我的”。使用枚举而非魔法数字(如直接用 0、1、2)是工程规范的基本要求:枚举成员 ShopTab.CART 在代码中具备自描述性,重构时改名安全,IDE 也能提供跳转与补全。枚举值显式赋为整数,便于与 @State currentTab 的比较判断(如 this.currentTab === ShopTab.ALL),也比字符串枚举在性能上更轻量。这种"枚举驱动 Tab 切换"是 ArkTS 多页签应用的常见模式。
四、工具函数:纯函数式的业务规则封装
代码片段
function formatPriceCNY(price: number): string {
return '¥' + price.toFixed(2);
}
function getStockStatus(stock: number): string {
if (stock > 50) {
return '充足';
}
if (stock > 0) {
return '紧张';
}
return '缺货';
}
function getStockColor(stock: number): string {
if (stock > 50) {
return SUCCESS_COLOR;
}
if (stock > 0) {
return WARNING_COLOR;
}
return DANGER_COLOR;
}
function getCategoryEmoji(category: string): string {
if (category === '电子产品') {
return '📱';
}
if (category === '服装配饰') {
return '👗';
}
if (category === '家居用品') {
return '🏠';
}
if (category === '食品饮料') {
return '🍜';
}
if (category === '图书文具') {
return '📚';
}
return '📦';
}

逐段解析
这一组工具函数体现了"纯函数封装业务规则"的设计哲学。所谓纯函数,是指输入相同则输出相同、且不产生副作用的函数——它们不依赖也不修改任何外部状态,只对入参做计算并返回结果。在声明式 UI 中,纯函数尤其适合放在 build() 方法内被反复调用,因为框架在 diff 重渲染时会多次执行构建逻辑,纯函数不会带来意外的状态污染。
formatPriceCNY 是最简单的一个:将数字价格格式化为带 ¥ 前缀、保留两位小数的字符串。toFixed(2) 是 JavaScript/TypeScript 原生的数字格式化方法,对 299 返回 "299.00",对 89 返回 "89.00"。统一在此处格式化,避免每个组件都写一遍 '¥' + price,也便于未来切换货币符号或增加千分位分隔。
getStockStatus 与 getStockColor 是一对"语义—颜色"的孪生函数,二者业务规则完全对应:库存大于 50 为"充足"配绿色,大于 0 为"紧张"配橙色,否则为"缺货"配红色。这种"同一阈值逻辑产出文字与颜色两个返回值"的拆分,是为了在 UI 中分别用于标签文本与标签底色——例如商品卡片中的库存徽标,文字用 getStockColor 返回色、背景用同色加透明度后缀 '20'(即 12.5% 不透明度)形成淡色底。两个函数虽然可以合并为一个返回 { text, color } 对象的函数,但拆开后各自职责单一、调用更灵活,是可接受的取舍。阈值 50 是一个硬编码业务常量,更严谨的做法是提取为 STOCK_WARNING_THRESHOLD,但在示例工程中直接书写也无可厚非。
getCategoryEmoji 是一个类目到 emoji 的映射函数,用一连串 if 而非 switch 或对象字面量映射实现。虽然写法略显冗长,但逻辑直白、易于增删类目。五个一级类目各自对应一个代表性 emoji,兜底返回通用包裹图标 📦。这个函数在本应用中其实未被直接调用(商品数据自带 emoji 字段),但其存在表明开发者考虑过"类目驱动的图标系统"这一设计方向,可视为预留的工具函数。整体来看,这四个函数共同构成应用的"业务规则层",将数据到展示的转换逻辑集中收口,是良好的关注点分离。
五、静态数据工厂:模拟后端数据源
代码片段
function getProductData(): ProductConfig[] {
const data: ProductConfig[] = [
{ id: 1, name: '智能蓝牙耳机 Pro', price: 299, category: '电子产品', stock: 120, rating: 4.8, emoji: '🎧' },
{ id: 2, name: '轻薄笔记本电脑', price: 5999, category: '电子产品', stock: 35, rating: 4.7, emoji: '💻' },
// ... 共 30 条商品,覆盖电子产品、服装配饰、家居用品、食品饮料、图书文具五大类目
{ id: 30, name: '彩色便利贴', price: 8, category: '图书文具', stock: 800, rating: 4.2, emoji: '📝' }
];
return data;
}
function getCartData(): CartItemConfig[] { /* 5 条购物车数据 */ }
function getOrderData(): OrderConfig[] { /* 6 条订单数据,状态含已签收/运输中/待发货 */ }
function getFavoriteData(): FavoriteConfig[] { /* 7 条收藏数据 */ }
function getStatData(): StatConfig[] {
const data: StatConfig[] = [
{ category: '电子产品', sales: 85, color: '#00695C' },
{ category: '服装配饰', sales: 62, color: '#00897B' },
{ category: '家居用品', sales: 48, color: '#26A69A' },
{ category: '食品饮料', sales: 73, color: '#4DB6AC' },
{ category: '图书文具', sales: 55, color: '#80CBC4' }
];
return data;
}

逐段解析
这一组 get...Data 函数是应用的"假数据工厂",在不接入真实后端的前提下为 UI 提供丰富的展示素材。将数据生成封装为函数而非直接声明顶层变量,有一个微妙但重要的好处:每次调用都返回新数组实例,避免多处复用同一引用导致的意外副作用。在主组件中,这些函数被 @State 初始化时调用一次(如 @State products: ProductConfig[] = getProductData()),此后状态由组件持有,与工厂解耦。
商品数据共 30 条,精心覆盖了五大类目,价格从 8 元(便利贴)到 5999 元(笔记本)跨度极大,库存从 18(紧张)到 800(充足)也具备梯度,评分集中在 4.1–4.8 之间——这种数据分布能充分触发 getStockStatus/getStockColor 的各个分支,便于测试不同状态下的 UI 表现。每条数据都带 emoji,使商品列表在无图片资源的情况下依然具备视觉辨识度,是示例工程中常见的"轻量化视觉方案"。订单数据的状态字段刻意混合了"已签收"“运输中”"待发货"三种值,用以驱动 OrderItemView 中状态徽标的条件配色逻辑;订单日期集中在 2026 年 7 月,模拟近期订单的时间分布。收藏数据是商品的一个子集快照,productId 指向原商品,便于未来做"取消收藏后回退到商品详情"等联动。统计数据 getStatData 返回五个类目的销售额与对应颜色,颜色取自主色调梯度的五个阶梯(从 #00695C 到 #80CBC4),保证柱状图既区分明显又色调统一。
这种"函数返回硬编码数组"的写法在真实工程中通常会被替换为 HTTP 请求(如 axios.get('/api/products')),但作为前端开发的本地 Mock 层,它具备几个优点:无网络依赖、可离线运行、数据可控可复现。更重要的是,这种封装使数据源的替换对 UI 层透明——只要替换 getProductData 的实现(改为异步请求并返回 Promise),调用方的主组件只需做最小调整,体现了"接口隔离"的工程价值。
六、商品卡片组件 ProductCard
代码片段
@Component
struct ProductCard {
@Prop product: ProductConfig;
onAddToCart?: () => void;
onEdit?: () => void;
onDelete?: () => void;
build() {
Column() {
Row() {
Text(this.product.emoji)
.fontSize(36)
.margin({ right: 12 })
Column() {
Text(this.product.name)
.fontSize(15)
.fontWeight(600)
.fontColor(TEXT_PRIMARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(formatPriceCNY(this.product.price))
.fontSize(16)
.fontWeight(700)
.fontColor(DANGER_COLOR)
Row().layoutWeight(1)
Text(getStockStatus(this.product.stock))
.fontSize(11)
.fontColor(getStockColor(this.product.stock))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(10)
.backgroundColor(getStockColor(this.product.stock) + '20')
}
.width('100%')
.margin({ top: 4 })
Row() {
Text('★ ' + this.product.rating.toString())
.fontSize(12)
.fontColor(WARNING_COLOR)
Row().layoutWeight(1)
Text(this.product.category)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(BG_COLOR)
.borderRadius(4)
}
.width('100%')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Button('加入购物车')
.fontSize(12)
.backgroundColor(PRIMARY_COLOR)
.fontColor(Color.White)
.borderRadius(16)
.height(30)
.padding({ left: 12, right: 12 })
.onClick(() => {
if (this.onAddToCart) {
this.onAddToCart();
}
})
Row().layoutWeight(1)
Button('编辑')
.fontSize(11)
.backgroundColor('#E0E0E0')
.fontColor(TEXT_PRIMARY)
.borderRadius(12)
.height(26)
.padding({ left: 8, right: 8 })
.onClick(() => {
if (this.onEdit) {
this.onEdit();
}
})
Button('删除')
.fontSize(11)
.backgroundColor('#FFEBEE')
.fontColor(DANGER_COLOR)
.borderRadius(12)
.height(26)
.padding({ left: 8, right: 8 })
.margin({ left: 6 })
.onClick(() => {
if (this.onDelete) {
this.onDelete();
}
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(CARD_BG)
.borderRadius(12)
.margin({ bottom: 10 })
}
}

逐段解析
ProductCard 是商品列表中的核心展示单元,也是本应用信息密度最高的组件之一。它通过 @Component 装饰器声明为一个独立组件,接收一个 @Prop product: ProductConfig 作为只读数据入参,并暴露三个可选回调 onAddToCart、onEdit、onDelete 供父组件注入业务逻辑。这种"数据进、事件出"的组件契约是 ArkTS 推荐的父子通信模式:子组件不直接修改全局状态,只通过回调将用户意图上报,由父组件决定如何更新状态,从而保证数据流的单向性。
@Prop 与 @State 的关键区别在于:@Prop 是父到子的单向绑定,父组件状态变化会同步刷新子组件的 @Prop,但子组件不能反向修改 @Prop;@State 则是组件内部的可变状态,仅本组件可读写。在本例中,ProductCard 只需要展示商品信息、不需要修改它,因此用 @Prop 是恰当的——若误用 @State,则父组件数据更新时子组件不会刷新,导致"购物车添加后商品列表未变"之类的诡异 bug。三个回调声明为可选(?),并在 onClick 中以 if (this.onAddToCart) 做空值守护,体现了防御式编程思想:即使父组件未提供回调,点击也不会报错。
布局上,ProductCard 采用"外层 Column 纵向分两段、上段 Row 横向分图标与信息列、下段 Row 横向排按钮"的嵌套结构。上段左侧是 36 号字号的 emoji 充当商品图标,右侧是一个 Column,纵向堆叠商品名称、价格行、评分行三组信息。商品名称设 maxLines(1) 与 textOverflow({ overflow: TextOverflow.Ellipsis }),确保长名称单行显示并以省略号截断,避免撑破卡片高度——这是移动端列表项的典型处理。价格行用 Row().layoutWeight(1) 作为弹性占位符将价格左对齐、库存徽标右对齐,库存徽标的背景色用 getStockColor(...) + '20' 实现淡色底,文字用同色实色,形成"浅底深字"的标签效果,是 CSS 中常见的 rgba 技巧在 ArkTS 中的等价实现(ArkTS 字符串色值支持 8 位 HEX,后两位为 alpha)。评分行同理,左评分右类目标签,类目标签用 BG_COLOR 浅青底配 TEXT_SECONDARY 灰字,与库存徽标形成视觉区分。
下段按钮区是本卡片的交互核心。三个按钮采用差异化视觉权重:"加入购物车"是主操作,用主色 PRIMARY_COLOR 实底白字、较大尺寸(高度 30、圆角 16),视觉最突出;"编辑"是次要操作,用浅灰底 #E0E0E0 配深字、较小尺寸(高度 26、圆角 12);"删除"是危险操作,用浅红底 #FFEBEE 配红字 DANGER_COLOR,与编辑按钮同尺寸但色彩警示。这种"主次危险"三色按钮体系是操作型卡片的经典设计。按钮间用 Row().layoutWeight(1) 撑开间距,删除按钮额外加 margin({ left: 6 }) 与编辑按钮保持间距。整个卡片用 padding(14)、borderRadius(12)、backgroundColor(CARD_BG) 收尾,形成一张漂浮在浅青背景上的白色圆角卡片,margin({ bottom: 10 }) 为卡片间留出呼吸空间。这种"白卡 + 圆角 + 间距"的视觉语言贯穿全应用,是 Material Design 卡片原则的忠实落地。
七、购物车条目组件 CartItemView
代码片段
@Component
struct CartItemView {
@Prop item: CartItemConfig;
onRemove?: () => void;
build() {
Row() {
Text(this.item.emoji)
.fontSize(32)
.margin({ right: 10 })
Column() {
Text(this.item.name)
.fontSize(14)
.fontWeight(500)
.fontColor(TEXT_PRIMARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(formatPriceCNY(this.item.price))
.fontSize(14)
.fontWeight(700)
.fontColor(DANGER_COLOR)
Text(' x' + this.item.quantity.toString())
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ left: 8 })
}
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(formatPriceCNY(this.item.price * this.item.quantity))
.fontSize(15)
.fontWeight(700)
.fontColor(PRIMARY_COLOR)
.margin({ right: 8 })
Button('✕')
.fontSize(14)
.backgroundColor('#FFEBEE')
.fontColor(DANGER_COLOR)
.borderRadius(14)
.width(28)
.height(28)
.onClick(() => {
if (this.onRemove) {
this.onRemove();
}
})
}
.width('100%')
.padding(12)
.backgroundColor(CARD_BG)
.borderRadius(10)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
}

逐段解析
CartItemView 的结构比 ProductCard 简洁,因为购物车条目只需展示与移除,不需编辑。它同样遵循"@Prop 数据入 + 可选回调出"的契约,但只暴露一个 onRemove 回调。布局是经典的"左图标—中信息—右操作"三段式横向卡片:左侧 32 号 emoji,中间 Column 堆叠名称与"单价 x 数量"行,右侧依次是小计金额与移除按钮。
中间信息列有两个值得品味的细节。其一,名称同样设 maxLines(1) + 省略号,与 ProductCard 保持一致的长文本处理策略。其二,价格行用 formatPriceCNY(this.item.price) 显示单价,紧跟 ' x' + this.item.quantity 显示数量,二者字号与颜色区分(单价 14 号红粗、数量 12 号灰),形成"主信息—辅助信息"的视觉层次。右侧的小计 formatPriceCNY(this.item.price * this.item.quantity) 是单价乘以数量的实计算结果,用主色 PRIMARY_COLOR 而非红色,与小计作为"汇总值"的语义呼应——红色用于"支出项单价"、主色用于"汇总金额",色彩语义分明。这种"单价红、小计青"的对比让用户一眼能区分"这件多少钱"与"这单共多少钱"。
移除按钮是一个 28x28 的圆形小按钮(borderRadius(14) 恰为宽度一半),用 ✕ 字符作为图标,浅红底配红字。它的 onClick 通过 onRemove 回调将移除意图上报,子组件本身不操作购物车数组——真正的数组操作(splice)发生在父组件的 @Builder cartItemBuilder 中。这种"子组件只报事件、父组件管数据"的分工,是 ArkTS 状态管理得以保持单向可追踪的关键。卡片整体用 padding(12)、borderRadius(10)、margin({ bottom: 8 }) 收尾,比 ProductCard 的圆角略小(10 vs 12)、间距略窄(8 vs 10),使购物车列表在视觉上比商品列表更紧凑——因为购物车通常条目数少于商品数,紧凑排列能在一屏内展示更多信息。
八、订单条目组件 OrderItemView
代码片段
@Component
struct OrderItemView {
@Prop order: OrderConfig;
build() {
Row() {
Text(this.order.emoji)
.fontSize(30)
.margin({ right: 10 })
Column() {
Text(this.order.productName)
.fontSize(14)
.fontWeight(500)
.fontColor(TEXT_PRIMARY)
Row() {
Text(formatPriceCNY(this.order.amount))
.fontSize(14)
.fontWeight(700)
.fontColor(DANGER_COLOR)
Row().layoutWeight(1)
Text(this.order.date)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
}
.width('100%')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(this.order.status)
.fontSize(12)
.fontColor(this.order.status === '已签收' ? SUCCESS_COLOR :
(this.order.status === '运输中' ? PRIMARY_COLOR : WARNING_COLOR))
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(this.order.status === '已签收' ? SUCCESS_COLOR + '15' :
(this.order.status === '运输中' ? PRIMARY_COLOR + '15' : WARNING_COLOR + '15'))
.borderRadius(10)
}
.width('100%')
.padding(12)
.backgroundColor(CARD_BG)
.borderRadius(10)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
}
逐段解析
OrderItemView 与 CartItemView 的布局骨架几乎一致,但有两个关键差异揭示了其业务语义的不同。第一,它没有 onRemove 回调——订单是已成交的历史记录,不可随意删除,因此组件是纯展示型的,体现了"订单不可变"的业务约束。第二,右侧不是操作按钮,而是一个状态徽标,其颜色根据 this.order.status 的值动态切换。
状态徽标的颜色逻辑用嵌套三元运算符实现:已签收 配绿色 SUCCESS_COLOR、运输中 配主色 PRIMARY_COLOR、其他(如 待发货)配橙色 WARNING_COLOR。文字色与背景色同源,背景额外加 '15' 后缀(约 8% 不透明度)形成淡底,与 ProductCard 中库存徽标的 '20' 后缀同理。这种"状态值映射到语义色"的做法让用户扫一眼即可分辨订单进度:绿色代表已完成、青色代表进行中、橙色代表待处理。值得注意的是,颜色逻辑在 fontColor 与 backgroundColor 两处重复书写了同一套三元判断,虽然略显冗长,但保证了文字色与底色的严格对应——若未来新增状态(如"已取消"),需在两处同步添加分支,是一个潜在的可维护性隐患,可考虑抽取为 getStatusColor(status) 工具函数。
信息列中,订单金额用 amount 字段(而非 price),呼应了 OrderConfig 中"订单金额快照"的语义。日期字段右对齐,用 11 号灰字,作为辅助信息弱化处理。整个组件的视觉权重集中于状态徽标,符合订单列表"以状态为检索维度"的使用场景。与 CartItemView 相比,OrderItemView 的 emoji 字号略小(30 vs 32),因为订单信息列只需两行(名称 + 金额/日期行),比购物车的两行略紧凑,整体高度更矮,使订单列表能在一屏内展示更多条目。
九、收藏条目组件 FavoriteItemView
代码片段
@Component
struct FavoriteItemView {
@Prop item: FavoriteConfig;
onRemove?: () => void;
build() {
Row() {
Text(this.item.emoji)
.fontSize(28)
.margin({ right: 10 })
Column() {
Text(this.item.name)
.fontSize(14)
.fontWeight(500)
.fontColor(TEXT_PRIMARY)
Text(formatPriceCNY(this.item.price))
.fontSize(13)
.fontColor(DANGER_COLOR)
.fontWeight(600)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Button('取消收藏')
.fontSize(11)
.backgroundColor('#FFF3E0')
.fontColor(WARNING_COLOR)
.borderRadius(12)
.height(28)
.padding({ left: 10, right: 10 })
.onClick(() => {
if (this.onRemove) {
this.onRemove();
}
})
}
.width('100%')
.padding(12)
.backgroundColor(CARD_BG)
.borderRadius(10)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
}
逐段解析
FavoriteItemView 是三个列表项组件中最简洁的一个,因为收藏实体本身字段最少(FavoriteConfig 仅 5 个字段)。它的信息列只有两行:名称与价格,没有数量、日期、评分等附加信息。右侧是一个"取消收藏"按钮,用浅橙底 #FFF3E0 配橙字 WARNING_COLOR——这里选用橙色而非红色,是有意区分"取消收藏"与"删除商品"两种操作的语义权重:取消收藏是可逆的轻度操作(商品仍在,可重新收藏),删除商品是不可逆的重度操作,因此前者用警示橙、后者用危险红,色彩语义与操作严重程度对应。
emoji 字号进一步缩小到 28,是三个列表项组件中最小的,因为收藏项信息量最少、行高最低,较小的图标能与整体紧凑感匹配。价格用 13 号红粗字,比 CartItemView 的 14 号略小,与收藏项的次要展示定位一致。onRemove 回调的空值守护与其他组件一致。整个组件的设计哲学是"最小信息 + 单一操作":收藏列表的用途是快速浏览心仪商品,用户要么点进去看详情(本应用未实现)、要么取消收藏,因此无需展示库存、评分等决策辅助信息,保持视觉极简。这种"按场景裁剪信息"的组件设计思路,比"一个万能卡片塞满所有字段"更符合移动端的注意力经济原则。
十、销售柱状图组件 SalesBarChart
代码片段
@Component
struct SalesBarChart {
@Prop barData: StatConfig[];
build() {
Column() {
Text('📊 各类目销售额统计')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 16 })
Row() {
ForEach(this.barData, (item: StatConfig, index: number) => {
Column() {
Text(item.sales.toString())
.fontSize(10)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
Column()
.width(40)
.height(item.sales * 2)
.backgroundColor(item.color)
.borderRadius({ topLeft: 4, topRight: 4 })
.animation({ duration: 500, curve: Curve.EaseOut })
Text(item.category)
.fontSize(9)
.fontColor(TEXT_SECONDARY)
.margin({ top: 6 })
.maxLines(1)
}
.alignItems(HorizontalAlign.Center)
.margin({ left: 8, right: 8 })
}, (item: StatConfig, index: number) => index.toString())
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(16)
.backgroundColor(CARD_BG)
.borderRadius(12)
.margin({ bottom: 10 })
}
}
逐段解析
SalesBarChart 是本应用中唯一的数据可视化组件,也是少数使用 ForEach 进行列表渲染的组件(其他列表多用"手动展开"策略,后文详述)。它接收一个 @Prop barData: StatConfig[],将每个 StatConfig 渲染为一根柱子。每根柱子是一个 Column,纵向堆叠三部分:顶部的数值标签、中间的柱体(一个空 Column 用 height 控制高度)、底部的类目标签。
柱体高度的算法是 item.sales * 2,即销售额乘以 2 作为像素高度——这是一个简单的线性缩放,sales 最大值 85 对应 170 像素柱高,最小值 48 对应 96 像素,差异足够明显但不会撑破卡片。这种"硬编码缩放系数"的做法在数据范围已知且稳定的示例中可行,但在真实工程中应改为 item.sales / maxValue * MAX_BAR_HEIGHT 的相对缩放,以适应数据动态变化。柱体用 borderRadius({ topLeft: 4, topRight: 4 }) 只圆顶部两角,模拟传统柱状图的"方底圆顶"造型。柱体颜色取自 item.color,而 getStatData 中已为每个类目分配了主色调梯度的不同阶梯色,使五根柱子既同色系又可区分。
ForEach 的第三个参数是键值生成器 (item, index) => index.toString(),用索引作为 key。这在数据顺序稳定、无增删的场景下可行,但若数据会动态变化,用索引作 key 会导致复用错位——更稳妥的做法是用 item.category 作为 key。值得注意的是,柱体上挂了 .animation({ duration: 500, curve: Curve.EaseOut }),这意味着当 barData 变化导致柱高重算时,高度过渡会以 500ms 的 EaseOut 曲线动画呈现,形成"柱子长高"的动效——这是 ArkTS 声明式动画的典型用法:开发者只需声明"动画属性 + 时长 + 曲线",框架自动在属性变化时插入过渡,无需手动驱动。外层 Row 用 justifyContent(FlexAlign.SpaceAround) 使五根柱子在水平方向均匀分布,alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐——这是柱状图的基本要求,否则柱子会顶部对齐导致视觉混乱。
整个图表组件没有任何交互(无 onClick、无回调),是纯展示型的。它被嵌入到"全部商品"Tab 的列表底部,作为商家视角的销售概览,与上方的商品列表形成"明细 + 概览"的信息层次。这种"列表 + 图表"的组合是电商后台的常见布局。
十一、商品表单弹窗 ProductFormModal
代码片段
@Component
struct ProductFormModal {
@Prop title: string;
@Prop initialName: string;
@Prop initialPrice: string;
@Prop initialCategory: string;
@Prop initialStock: string;
@Prop visible: boolean;
onConfirm?: (name: string, price: string, category: string, stock: string) => void;
onCancel?: () => void;
build() {
if (this.visible) {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.5)
.onClick(() => {
if (this.onCancel) {
this.onCancel();
}
})
Column() {
Text(this.title)
.fontSize(18)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 16 })
Column() {
Text('商品名称')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
.alignSelf(ItemAlign.Start)
TextInput({ text: this.initialName, placeholder: '请输入商品名称' })
.fontSize(14)
.height(40)
.backgroundColor(BG_COLOR)
.borderRadius(8)
.padding({ left: 12 })
}
.width('100%')
.margin({ bottom: 12 })
Row() {
Column() {
Text('价格')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
.alignSelf(ItemAlign.Start)
TextInput({ text: this.initialPrice, placeholder: '¥' })
.fontSize(14)
.height(40)
.backgroundColor(BG_COLOR)
.borderRadius(8)
.padding({ left: 12 })
.type(InputType.Number)
}
.layoutWeight(1)
Column() {
Text('库存')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
.alignSelf(ItemAlign.Start)
TextInput({ text: this.initialStock, placeholder: '数量' })
.fontSize(14)
.height(40)
.backgroundColor(BG_COLOR)
.borderRadius(8)
.padding({ left: 12 })
.type(InputType.Number)
}
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.margin({ bottom: 12 })
Column() {
Text('分类')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
.alignSelf(ItemAlign.Start)
TextInput({ text: this.initialCategory, placeholder: '请输入商品分类' })
.fontSize(14)
.height(40)
.backgroundColor(BG_COLOR)
.borderRadius(8)
.padding({ left: 12 })
}
.width('100%')
.margin({ bottom: 20 })
Row() {
Button('取消')
.fontSize(14)
.backgroundColor('#E0E0E0')
.fontColor(TEXT_PRIMARY)
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => {
if (this.onCancel) {
this.onCancel();
}
})
Button('确认')
.fontSize(14)
.backgroundColor(PRIMARY_COLOR)
.fontColor(Color.White)
.borderRadius(20)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
if (this.onConfirm) {
this.onConfirm(this.initialName, this.initialPrice, this.initialCategory, this.initialStock);
}
})
}
.width('100%')
}
.width('85%')
.padding(24)
.backgroundColor(CARD_BG)
.borderRadius(16)
}
.width('100%')
.height('100%')
}
}
}
逐段解析
ProductFormModal 是本应用中复用度最高的弹窗组件——同一个组件实例被用于"添加商品"和"编辑商品"两个场景,通过 title 与 initial* 一组 @Prop 区分用途。这种"一个表单组件服务多个场景"的设计是组件复用的经典范式:避免为添加和编辑各写一个几乎相同的表单,减少重复代码。
组件接收六个 @Prop:title 控制标题文案,initialName/initialPrice/initialCategory/initialStock 是四个表单字段的初始值,visible 控制弹窗显隐。build() 最外层是 if (this.visible),当 visible 为 false 时整个 build 返回空,组件不渲染任何内容——这是 ArkTS 中"条件渲染"控制组件存在性的标准写法,相比用 visibility 属性控制显隐,if 条件渲染会真正从组件树中移除节点,节省内存与渲染开销。
弹窗的视觉结构用 Stack 实现两层叠加:底层是一个全屏黑色遮罩 Column(backgroundColor('#000000') + opacity(0.5)),覆盖整个屏幕以阻挡下层交互,点击遮罩触发 onCancel 关闭弹窗——这是移动端弹窗的"点击外部关闭"惯例;上层是一个 85% 宽度的白色圆角卡片,承载实际表单内容。Stack 的子元素默认居中堆叠,因此白色卡片自动水平居中、垂直居中于屏幕。
表单内容纵向排列四个字段:商品名称(独占一行)、价格与库存(横向并排各占一半)、分类(独占一行)。每个字段的标签用 12 号灰字左对齐(alignSelf(ItemAlign.Start)),输入框 TextInput 用浅青底 BG_COLOR、圆角 8、高度 40,统一的视觉规范。价格与库存的 TextInput 设 .type(InputType.Number),弹出数字键盘并限制输入为数字,是移动端表单的基本优化——分类字段未设类型,因为类目是文本。值得注意的是,TextInput 的 text 参数绑定的是 this.initialName 等 @Prop,这意味着输入框的显示值由父组件传入的初始值决定,但 @Prop 是单向只读的,用户在输入框中键入的新值并不会回写到 this.initialName——这是一个设计上的微妙之处:在确认按钮的 onConfirm 回调中,传出的仍是 this.initialName 等 @Prop 初值,而非用户实际输入的值。这暗示本示例的表单提交逻辑是简化的,真实场景下应将 initial* 改为 @State 或使用 @Link/@Watch 双向绑定以捕获用户输入。即便如此,组件的整体结构、视觉设计、遮罩交互模式都极具参考价值。
底部"取消""确认"按钮采用 layoutWeight(1) 等分宽度,取消用灰底、确认用主色实底,视觉权重区分明确。onConfirm 回调签名携带四个字符串参数,父组件据此执行实际的添加或编辑逻辑——虽然如前所述,传出的参数值存在局限性,但回调契约的设计是合理的。
十二、删除确认弹窗 DeleteConfirmModal
代码片段
@Component
struct DeleteConfirmModal {
@Prop visible: boolean;
@Prop itemName: string;
onConfirm?: () => void;
onCancel?: () => void;
build() {
if (this.visible) {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.5)
.onClick(() => {
if (this.onCancel) {
this.onCancel();
}
})
Column() {
Text('⚠️ 确认删除')
.fontSize(18)
.fontWeight(700)
.fontColor(DANGER_COLOR)
.margin({ bottom: 12 })
Text('确定要删除 "' + this.itemName + '" 吗?此操作不可撤销。')
.fontSize(14)
.fontColor(TEXT_SECONDARY)
.textAlign(TextAlign.Center)
.margin({ bottom: 24 })
Row() {
Button('取消')
.fontSize(14)
.backgroundColor('#E0E0E0')
.fontColor(TEXT_PRIMARY)
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => {
if (this.onCancel) {
this.onCancel();
}
})
Button('删除')
.fontSize(14)
.backgroundColor(DANGER_COLOR)
.fontColor(Color.White)
.borderRadius(20)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
if (this.onConfirm) {
this.onConfirm();
}
})
}
.width('100%')
}
.width('80%')
.padding(24)
.backgroundColor(CARD_BG)
.borderRadius(16)
}
.width('100%')
.height('100%')
}
}
}
逐段解析
DeleteConfirmModal 与 ProductFormModal 在结构上高度同构——都是 if (visible) 守卫 + Stack 遮罩叠加 + 白色圆角卡片的内容三段式,但语义截然不同:前者是危险操作的二次确认,后者是表单录入。这种"结构复用、语义分化"的现象在组件库设计中很常见,也提示我们可进一步抽取一个通用的 ModalShell 基础组件,将遮罩 + 卡片 + 按钮行的骨架封装起来,由具体弹窗填充内容——本应用尚未做这一层抽象,属于可优化的工程点。
视觉上,删除确认弹窗刻意营造警示感:标题前缀 ⚠️ 警告图标,标题文字用 DANGER_COLOR 红色,正文用 textAlign(TextAlign.Center) 居中并明确提示"此操作不可撤销",从色彩、图标、文案三重维度强化危险感知。卡片宽度设为 80%(比表单弹窗的 85% 更窄),因为确认弹窗内容更少、无需表单字段,窄一些反而更显聚焦。按钮区同样等分两按钮,但"删除"按钮用红色实底白字,与 ProductFormModal 的主色确认按钮形成对比——红色按钮在这里是"明知危险仍要执行"的视觉信号,符合"危险操作用红色"的交互规范。
itemName 通过 @Prop 传入,父组件在调用时已经从 products 数组中根据 deleteProductIndex 取出对应商品名(见后文主组件分析),确保弹窗显示的是"即将被删除的商品"的真实名称,而非泛泛的"确定删除吗"。这种"在确认文案中嵌入具体对象名"的做法能显著降低误操作概率,是危险操作确认的最佳实践。onConfirm 与 onCancel 回调的空值守护与其他组件一致,整体代码风格高度统一。
十三、主入口组件 ECommerceApp:状态声明
代码片段
@Entry
@Component
struct ECommerceApp {
@State currentTab: ShopTab = ShopTab.ALL;
@State products: ProductConfig[] = getProductData();
@State cartItems: CartItemConfig[] = getCartData();
@State orders: OrderConfig[] = getOrderData();
@State favorites: FavoriteConfig[] = getFavoriteData();
@State stats: StatConfig[] = getStatData();
@State showAddModal: boolean = false;
@State showEditModal: boolean = false;
@State showDeleteModal: boolean = false;
@State editProductIndex: number = -1;
@State deleteProductIndex: number = -1;
@State editName: string = '';
@State editPrice: string = '';
@State editCategory: string = '';
@State editStock: string = '';
...
}
逐段解析
ECommerceApp 是整个应用的根组件,由 @Entry 装饰标记为页面入口,@Component 声明为组件。它集中持有应用的所有状态,是"单一数据源"原则的执行者。这里的 14 个 @State 变量可按职责分为四组。
第一组是业务数据状态:products(商品列表)、cartItems(购物车)、orders(订单)、favorites(收藏)、stats(销售统计),均通过前文的 get...Data 工厂函数初始化。这五个数组是应用的核心数据资产,所有列表渲染与计算都基于它们。将它们声明为 @State 而非普通属性,意味着任何对它们的赋值(包括 push、splice 后的引用替换)都会触发依赖它们的 UI 重新构建。
第二组是导航状态:currentTab,类型为 ShopTab 枚举,初值 ShopTab.ALL。它是底部 TabBar 切换的唯一驱动,决定了 contentArea 构建器渲染哪个子页面。用枚举而非字符串存储,保证值域可控。
第三组是弹窗显隐状态:showAddModal、showEditModal、showDeleteModal,三个布尔值分别控制添加、编辑、删除三个弹窗的显隐。初值全为 false,意味着应用启动时无弹窗。这种"每个弹窗一个布尔开关"的设计直白但有效;若弹窗数量增多,可考虑用枚举 + 单一 activeModal 变量统一管理。
第四组是编辑上下文状态:editProductIndex、deleteProductIndex(被编辑/删除商品的索引,初值 -1 表示无选中)、editName/editPrice/editCategory/editStock(编辑表单的四个字段值)。这组状态在用户点击"编辑"时被填充、在弹窗确认时被消费,是"操作意图的临时载体"。将它们放在根组件而非 ProductFormModal 内部,是因为根组件需要在确认时根据 editProductIndex 找到目标商品并更新 products 数组——表单组件本身不应知道"编辑哪条商品",这一职责属于数据持有者。
这种"根组件集中持有所有状态"的模式在中小型应用中清晰易维护,但随应用规模增长会因状态膨胀导致根组件臃肿。ArkTS 提供的进阶方案是 @Provide/@Consume 跨层级传递、或使用 AppStorage 全局存储,但本应用规模下,集中式 @State 仍是恰当选择。
十四、构建器方法:bottomTabItem 与列表项 Builder
代码片段
@Builder bottomTabItem(tab: ShopTab, label: string, icon: string) {
Column() {
Text(icon)
.fontSize(22)
.fontColor(this.currentTab === tab ? PRIMARY_COLOR : TEXT_SECONDARY)
Text(label)
.fontSize(11)
.fontColor(this.currentTab === tab ? PRIMARY_COLOR : TEXT_SECONDARY)
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 6 })
.onClick(() => {
this.currentTab = tab;
})
}
@Builder productCardBuilder(product: ProductConfig) {
ProductCard({
product: product,
onAddToCart: () => {
this.cartItems.push({
id: this.cartItems.length + 1,
productId: product.id,
name: product.name,
price: product.price,
quantity: 1,
emoji: product.emoji
} as CartItemConfig);
},
onEdit: () => {
this.editProductIndex = product.id - 1;
this.editName = product.name;
this.editPrice = product.price.toString();
this.editCategory = product.category;
this.editStock = product.stock.toString();
this.showEditModal = true;
},
onDelete: () => {
this.deleteProductIndex = product.id - 1;
this.showDeleteModal = true;
}
})
}
@Builder cartItemBuilder(item: CartItemConfig) {
CartItemView({
item: item,
onRemove: () => {
const idx: number = this.cartItems.findIndex((c: CartItemConfig) => c.id === item.id);
if (idx >= 0) {
this.cartItems.splice(idx, 1);
}
}
})
}
@Builder orderItemBuilder(order: OrderConfig) {
OrderItemView({ order: order })
}
@Builder favoriteItemBuilder(item: FavoriteConfig) {
FavoriteItemView({
item: item,
onRemove: () => {
const idx: number = this.favorites.findIndex((f: FavoriteConfig) => f.id === item.id);
if (idx >= 0) {
this.favorites.splice(idx, 1);
}
}
})
}
逐段解析
@Builder 是 ArkTS 中介于"组件"与"内联代码"之间的一种复用单元。它本质上是一个"返回 UI 的方法",但能用 this 访问宿主组件的状态,且调用时不会引入新的组件层级——这与自定义 @Component 不同,后者会创建独立的组件实例与渲染节点。@Builder 适合抽取"需要访问宿主状态、又不想新增组件层级"的 UI 片段。
bottomTabItem 是底部导航项的构建器,接收 tab(枚举)、label、icon 三参,渲染一个纵向排列的图标 + 文字。关键在于 fontColor 用三元 this.currentTab === tab ? PRIMARY_COLOR : TEXT_SECONDARY 实现选中态高亮——当前 Tab 与该项匹配时图标和文字变主色,否则灰色。onClick 直接赋值 this.currentTab = tab 切换页签,是导航的核心逻辑。每个 Tab 项 layoutWeight(1) 等分底部栏宽度,padding({ top: 8, bottom: 6 }) 控制触摸区域高度。这个 Builder 在 build() 中被调用五次,分别对应五个 Tab,避免了重复书写五遍相同的 Column 结构。
productCardBuilder 是商品卡片的包装构建器,它实例化 ProductCard 并注入三个回调。onAddToCart 用 this.cartItems.push({...} as CartItemConfig) 向购物车数组追加新条目,id 取 this.cartItems.length + 1 模拟自增主键,quantity 固定为 1。这里用 as CartItemConfig 做类型断言,确保字面量符合接口契约。onEdit 将商品信息填充到 editProductIndex 与四个 edit* 字段,然后置 showEditModal = true 弹出编辑窗——这一连串赋值是"打开编辑弹窗"的完整上下文准备。onDelete 仅记录 deleteProductIndex 并弹出删除确认窗,实际删除在确认回调中执行,体现了"延迟执行危险操作"的设计。
cartItemBuilder 与 favoriteItemBuilder 的 onRemove 回调都采用"findIndex 定位 + splice 删除"的两步操作,而非直接按数组索引删除。这是因为列表渲染时索引可能与 item.id 不一致(数组中可能有间隙),用 findIndex 按 id 精确定位更稳健。orderItemBuilder 最简单,因为 OrderItemView 无回调,直接传 order 即可。这组 Builder 将"组件实例化 + 回调注入"集中收口,使各 Tab 页的构建器代码保持简洁——Tab 页只需调用 this.productCardBuilder(this.products[i]) 即可,无需关心回调细节。
十五、全部商品 Tab:allProductsTab
代码片段
@Builder allProductsTab() {
Scroll() {
Column() {
Text('全部商品 (' + this.products.length.toString() + ')')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 12 })
if (this.products.length >= 1) { this.productCardBuilder(this.products[0]) }
if (this.products.length >= 2) { this.productCardBuilder(this.products[1]) }
if (this.products.length >= 3) { this.productCardBuilder(this.products[2]) }
if (this.products.length >= 4) { this.productCardBuilder(this.products[3]) }
if (this.products.length >= 5) { this.productCardBuilder(this.products[4]) }
if (this.products.length >= 6) { this.productCardBuilder(this.products[5]) }
if (this.products.length >= 7) { this.productCardBuilder(this.products[6]) }
if (this.products.length >= 8) { this.productCardBuilder(this.products[7]) }
if (this.products.length >= 9) { this.productCardBuilder(this.products[8]) }
if (this.products.length >= 10) { this.productCardBuilder(this.products[9]) }
SalesBarChart({ barData: this.stats })
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
}
逐段解析
allProductsTab 是默认展示的首屏,结构为"标题 + 商品卡片列表 + 销售柱状图"。最外层 Scroll 提供纵向滚动能力,因为商品卡片 + 图表的总高度会超出屏幕。layoutWeight(1) 使其填满 header 与底部 TabBar 之间的空间。
列表渲染采用了本应用最具特色的策略——手动展开 + 边界检查,而非 ForEach。连续 10 行 if (this.products.length >= N) { this.productCardBuilder(this.products[N-1]) },逐条检查数组长度是否足够,足够则渲染第 N 项。这种写法在传统观念下显得"反模式"——明明可以用 ForEach(this.products, ...) 一行搞定,为何要展开 10 行?深入分析可发现几个考量:其一,避免 ForEach 的 key 生成问题,前文 SalesBarChart 用索引作 key 在数据变化时有复用错位风险,手动展开则每条都是独立的 if 分支,无 key 概念,渲染稳定;其二,控制渲染数量上限,10 行 if 实际上限制了最多渲染 10 条商品,即使 products 有 30 条也只显示前 10——这是一种"懒加载/分页"的简化模拟,避免一次性渲染过多节点导致性能下降;其三,ArkTS 早期版本对 ForEach 在 @Builder 中的支持存在限制,手动展开是兼容性更佳的稳妥写法。当然,这种写法的代价是代码冗长且上限固定,真实工程中应优先用 ForEach + 合理 key + LazyForEach 懒加载。
标题 '全部商品 (' + this.products.length.toString() + ')' 动态显示商品总数,让用户对列表规模有预期。列表底部紧跟 SalesBarChart({ barData: this.stats }),将销售概览与商品明细同屏呈现,是"商家管理视角"的信息组织——既看到具体商品,又看到类目销售分布。整个 Tab 用 padding({ left: 16, right: 16, top: 8, bottom: 16 }) 留出左右边距与上下呼吸,与其他 Tab 保持一致。
十六、购物车 Tab:cartTab
代码片段
@Builder cartTab() {
Column() {
Text('🛒 购物车 (' + this.cartItems.length.toString() + ')')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 12 })
.alignSelf(ItemAlign.Start)
if (this.cartItems.length === 0) {
Column() {
Text('🛒')
.fontSize(48)
.margin({ bottom: 8 })
Text('购物车为空')
.fontSize(14)
.fontColor(TEXT_SECONDARY)
Text('快去添加商品吧')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column() {
if (this.cartItems.length >= 1) { this.cartItemBuilder(this.cartItems[0]) }
if (this.cartItems.length >= 2) { this.cartItemBuilder(this.cartItems[1]) }
if (this.cartItems.length >= 3) { this.cartItemBuilder(this.cartItems[2]) }
if (this.cartItems.length >= 4) { this.cartItemBuilder(this.cartItems[3]) }
if (this.cartItems.length >= 5) { this.cartItemBuilder(this.cartItems[4]) }
Row() {
Text('合计:')
.fontSize(14)
.fontColor(TEXT_SECONDARY)
Text(formatPriceCNY(this.getCartTotal()))
.fontSize(20)
.fontWeight(700)
.fontColor(DANGER_COLOR)
Row().layoutWeight(1)
Button('去结算')
.fontSize(14)
.backgroundColor(PRIMARY_COLOR)
.fontColor(Color.White)
.borderRadius(20)
.height(40)
.padding({ left: 24, right: 24 })
}
.width('100%')
.padding(16)
.backgroundColor(CARD_BG)
.borderRadius(12)
.margin({ top: 8 })
}
.width('100%')
}
.width('100%')
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
.padding({ left: 16, right: 16, top: 8 })
}
逐段解析
cartTab 的核心特色是空状态与列表状态的二分处理。最外层 Column 内,先判断 this.cartItems.length === 0:若是空,渲染一个居中的空状态占位(大号购物车 emoji + “购物车为空” + "快去添加商品吧"提示),用 layoutWeight(1) + justifyContent(FlexAlign.Center) 使其垂直居中填满区域;若非空,渲染 Scroll 包裹的列表 + 结算栏。这种"空态/列表态"的条件分支是列表型页面的标准设计,能避免用户面对空白屏幕的困惑。
列表部分同样采用"手动展开 + 边界检查"策略,上限 5 条。列表底部是结算栏,结构为"合计标签 + 总金额 + 弹性占位 + 去结算按钮"。总金额通过 this.getCartTotal() 计算(后文分析),用 20 号红粗字突出显示——这是整个购物车页面的视觉焦点,因为"要付多少钱"是用户最关心的信息。"去结算"按钮用主色实底,padding({ left: 24, right: 24 }) 使其比普通按钮更宽,强化"主要操作"的视觉权重。结算栏整体用白卡包裹,margin({ top: 8 }) 与上方列表区分。
标题用 alignSelf(ItemAlign.Start) 左对齐,与 allProductsTab 的默认居中对齐形成细微差异——这种差异并非刻意,而是两个 Builder 独立编写的自然结果,但视觉上影响不大。整个 Tab 的 padding({ left: 16, right: 16, top: 8 }) 未设 bottom,因为底部有全局 TabBar 提供视觉收边。空状态的设计值得称道:不仅提示"购物车为空",还附加引导文案"快去添加商品吧",形成"告知现状 + 引导行动"的完整空态体验,比单纯显示"空"更友好。
十七、订单 Tab:ordersTab
代码片段
@Builder ordersTab() {
Scroll() {
Column() {
Text('📋 我的订单 (' + this.orders.length.toString() + ')')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 12 })
if (this.orders.length >= 1) { this.orderItemBuilder(this.orders[0]) }
if (this.orders.length >= 2) { this.orderItemBuilder(this.orders[1]) }
if (this.orders.length >= 3) { this.orderItemBuilder(this.orders[2]) }
if (this.orders.length >= 4) { this.orderItemBuilder(this.orders[3]) }
if (this.orders.length >= 5) { this.orderItemBuilder(this.orders[4]) }
if (this.orders.length >= 6) { this.orderItemBuilder(this.orders[5]) }
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
}
逐段解析
ordersTab 是结构最简单的列表型 Tab,因为它既无空状态分支(订单数据固定 6 条),也无结算栏等附加组件。整个 Builder 就是一个 Scroll 包裹的 Column,内含标题 + 6 行 orderItemBuilder 调用。手动展开上限为 6,与订单数据条数恰好匹配。
订单页未做空状态处理,可能基于一个业务假设:商家管理员视角下,订单列表始终有数据(已成交订单不会为空)。但更严谨的做法是与其他 Tab 一致地提供空态,因为新注册商家在首单成交前确实可能无订单。这一细节反映出示例工程在完整性上的取舍——优先展示核心功能,空态处理在购物车与收藏两处已示范,订单页省略以减少重复。
标题文案"我的订单"带 📋 前缀,与购物车的 🛒、收藏的 ❤️ 形成统一的"emoji + 文案"标题风格,增强各 Tab 的视觉识别。padding({ left: 16, right: 16, top: 8, bottom: 16 }) 与 allProductsTab 完全一致,保证列表型 Tab 的边距统一。整个 Tab 的设计哲学是"纯展示、零操作"——订单不可删、不可编辑,用户只能浏览,因此组件交互极简,信息密度由 OrderItemView 的状态徽标提供。
十八、收藏 Tab:favoritesTab
代码片段
@Builder favoritesTab() {
Column() {
Text('❤️ 我的收藏 (' + this.favorites.length.toString() + ')')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 12 })
.alignSelf(ItemAlign.Start)
if (this.favorites.length === 0) {
Column() {
Text('❤️')
.fontSize(48)
.margin({ bottom: 8 })
Text('暂无收藏')
.fontSize(14)
.fontColor(TEXT_SECONDARY)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column() {
if (this.favorites.length >= 1) { this.favoriteItemBuilder(this.favorites[0]) }
if (this.favorites.length >= 2) { this.favoriteItemBuilder(this.favorites[1]) }
if (this.favorites.length >= 3) { this.favoriteItemBuilder(this.favorites[2]) }
if (this.favorites.length >= 4) { this.favoriteItemBuilder(this.favorites[3]) }
if (this.favorites.length >= 5) { this.favoriteItemBuilder(this.favorites[4]) }
if (this.favorites.length >= 6) { this.favoriteItemBuilder(this.favorites[5]) }
if (this.favorites.length >= 7) { this.favoriteItemBuilder(this.favorites[6]) }
}
.width('100%')
}
.width('100%')
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
.padding({ left: 16, right: 16, top: 8 })
}
逐段解析
favoritesTab 与 cartTab 结构高度对称——同样有空状态/列表状态二分、同样手动展开列表、同样标题左对齐。差异仅在三处:其一,空状态文案为"暂无收藏"且只有一行(购物车空态有两行引导),引导性略弱;其二,列表无结算栏,因为收藏无"结算"语义;其三,手动展开上限为 7,与收藏数据条数匹配。
这种"购物车与收藏结构对称"的现象并非偶然,而是二者业务语义相近的体现——都是"用户主动收集的商品集合",区别仅在"购物车 = 待结算、收藏 = 待回购"。从工程角度,这两个 Tab 可进一步抽象为一个通用的"ItemCollectionTab"构建器,通过参数注入列表项 Builder、空态文案、可选的底部操作栏,从而消除大量重复代码。本应用未做这一层抽象,保持各 Tab 独立编写,可读性更佳但冗余度略高,是"DRY 原则"与"显式直白"之间的权衡。
收藏空态的设计可改进之处在于:缺少引导文案。当用户首次进入收藏页看到"暂无收藏"时,没有告知"如何收藏商品"的引导,可能产生困惑。对比购物车空态的"快去添加商品吧",收藏空态可补充"逛逛商品页点爱心收藏"之类的引导,提升新用户引导体验。这是产品细节层面的优化点,不影响技术架构。
十九、个人中心 Tab:profileTab 与 profOptionItemBuilder
代码片段
@Builder profileTab() {
Scroll() {
Column() {
Row() {
Column()
.width(60)
.height(60)
.borderRadius(30)
.backgroundColor(PRIMARY_COLOR)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column() {
Text('商家管理员')
.fontSize(18)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
Text('shop@example.com')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding(16)
.backgroundColor(CARD_BG)
.borderRadius(12)
.margin({ bottom: 12 })
Column() {
this.profOptionItemBuilder('📊', '销售统计', '查看详情')
this.profOptionItemBuilder('📦', '商品管理', this.products.length.toString() + ' 件商品')
this.profOptionItemBuilder('💰', '本月收入', '¥12,580.00')
this.profOptionItemBuilder('⭐', '店铺评分', '4.7 / 5.0')
this.profOptionItemBuilder('🔔', '消息通知', '3 条未读')
this.profOptionItemBuilder('⚙️', '设置', '')
}
.width('100%')
.backgroundColor(CARD_BG)
.borderRadius(12)
.padding({ top: 4, bottom: 4 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
}
@Builder profOptionItemBuilder(icon: string, title: string, desc: string) {
Row() {
Text(icon)
.fontSize(22)
.margin({ right: 10 })
Column() {
Text(title)
.fontSize(14)
.fontWeight(500)
.fontColor(TEXT_PRIMARY)
if (desc !== '') {
Text(desc)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ top: 1 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(18)
.fontColor(TEXT_SECONDARY)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
逐段解析
profileTab 是唯一非列表型的内容 Tab,采用"用户信息卡 + 选项列表"的经典个人中心布局。顶部用户信息卡是一个 Row:左侧 60x60 的圆形头像占位(用主色实底圆,无实际图片),右侧纵向堆叠"商家管理员"昵称与邮箱。这种"圆形头像 + 昵称 + 副信息"的头部是个人中心页的标准开头,简洁清晰。头像用纯色圆形而非图片,是示例工程的轻量化处理,真实场景应替换为 Image 组件加载用户头像 URL。
下方选项列表用 profOptionItemBuilder 生成六行,每行结构为"emoji 图标 + 标题 + 描述 + 右箭头"。profOptionItemBuilder 是一个参数化 Builder,接收 icon、title、desc 三参,其中 desc 为空时不渲染描述行(if (desc !== '')),使"设置"等无描述的项保持单行高度。右侧 › 箭头是 iOS 风格的"进入下一级"指示符,提示用户该项可点击进入详情。值得注意的是,选项列表整体包在一个白卡内,各行之间无分割线——这种"无分割线卡片"风格更现代,但牺牲了行间视觉边界,依赖 padding 的上下间距区分各行。
六项内容中,"商品管理"的描述动态显示 this.products.length.toString() + ' 件商品',将商品总数与个人中心联动,体现了状态在 Tab 间的共享——因为 products 是根组件的 @State,所有 Tab 都能读取其最新值。其他项的描述为硬编码(如"¥12,580.00"“4.7 / 5.0”),模拟静态数据。整个 profileTab 无任何交互回调(选项项无 onClick),是纯展示型的,真实场景下应补全各项点击跳转逻辑。
二十、购物车合计计算与内容区路由
代码片段
private getCartTotal(): number {
let total: number = 0;
for (let i: number = 0; i < this.cartItems.length; i++) {
total += this.cartItems[i].price * this.cartItems[i].quantity;
}
return total;
}
@Builder contentArea() {
if (this.currentTab === ShopTab.ALL) {
this.allProductsTab()
} else if (this.currentTab === ShopTab.CART) {
this.cartTab()
} else if (this.currentTab === ShopTab.ORDERS) {
this.ordersTab()
} else if (this.currentTab === ShopTab.FAVORITES) {
this.favoritesTab()
} else {
this.profileTab()
}
}
逐段解析
getCartTotal 是一个 private 方法,用传统 for 循环累加每条购物车项的 price * quantity。相比 reduce 或 forEach 的函数式写法,for 循环在 ArkTS 中性能更可预测,且类型标注更直观(let total: number = 0、let i: number = 0)。方法声明为 private 限制仅组件内部可调用,是封装性的体现。这个方法在 cartTab 的结算栏中被调用,每次购物车数据变化时,由于 cartItems 是 @State,build 会重新执行,getCartTotal 也会重新计算并刷新合计显示——这就是声明式 UI 的"状态驱动刷新"机制。
contentArea 是一个路由构建器,根据 currentTab 的值用 if/else if/else 链选择渲染哪个 Tab 的 Builder。这种"枚举驱动条件渲染"是 ArkTS 多页签应用的轻量级路由方案——无需引入路由框架,纯靠状态变量与条件分支切换内容区。每次 currentTab 变化(用户点击底部 Tab),contentArea 重新执行,对应分支的 Builder 被调用,旧 Tab 的 UI 从组件树移除、新 Tab 的 UI 挂载。这种方案的优点是简单直接、无额外依赖;缺点是 Tab 切换时状态不保留(如滚动位置会重置),因为组件被销毁重建。若需保留各 Tab 状态,可用 Tabs 组件的懒加载模式,但本应用选择最简方案。
if/else if/else 链的顺序与 ShopTab 枚举值顺序一致,else 兜底渲染 profileTab,对应 ShopTab.PROFILE。这种写法比 switch 在 ArkTS 的 @Builder 中更常见,因为 @Builder 内对 switch 的支持在某些版本下存在限制,if/else 链兼容性更好。整个 contentArea 是主组件 build() 中内容区的唯一入口,将五个 Tab 的复杂度隔离在各自 Builder 中,build() 本身保持简洁。
二十一、主构建方法 build:整体布局装配
代码片段
build() {
Stack() {
Column() {
Row() {
Text('🏪 在线商城')
.fontSize(20)
.fontWeight(700)
.fontColor(Color.White)
Row().layoutWeight(1)
Button('+ 添加')
.fontSize(12)
.backgroundColor(Color.White)
.fontColor(PRIMARY_COLOR)
.borderRadius(16)
.height(32)
.padding({ left: 14, right: 14 })
.onClick(() => {
this.showAddModal = true;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 12 })
.backgroundColor(PRIMARY_COLOR)
this.contentArea()
Row() {
this.bottomTabItem(ShopTab.ALL, '全部', '🏠')
this.bottomTabItem(ShopTab.CART, '购物车', '🛒')
this.bottomTabItem(ShopTab.ORDERS, '订单', '📋')
this.bottomTabItem(ShopTab.FAVORITES, '收藏', '❤️')
this.bottomTabItem(ShopTab.PROFILE, '我的', '👤')
}
.width('100%')
.backgroundColor(CARD_BG)
}
.width('100%')
.height('100%')
.backgroundColor(BG_COLOR)
ProductFormModal({
title: '添加商品',
initialName: '',
initialPrice: '',
initialCategory: '',
initialStock: '',
visible: this.showAddModal,
onConfirm: (name: string, price: string, category: string, stock: string) => {
this.showAddModal = false;
},
onCancel: () => {
this.showAddModal = false;
}
})
ProductFormModal({
title: '编辑商品',
initialName: this.editName,
initialPrice: this.editPrice,
initialCategory: this.editCategory,
initialStock: this.editStock,
visible: this.showEditModal,
onConfirm: (name: string, price: string, category: string, stock: string) => {
this.showEditModal = false;
},
onCancel: () => {
this.showEditModal = false;
}
})
DeleteConfirmModal({
visible: this.showDeleteModal,
itemName: this.deleteProductIndex >= 0 && this.deleteProductIndex < this.products.length
? this.products[this.deleteProductIndex].name : '',
onConfirm: () => {
if (this.deleteProductIndex >= 0 && this.deleteProductIndex < this.products.length) {
this.products.splice(this.deleteProductIndex, 1);
this.products = [...this.products];
}
this.showDeleteModal = false;
},
onCancel: () => {
this.showDeleteModal = false;
}
})
}
.width('100%')
.height('100%')
}
逐段解析
build() 是整个应用的装配中枢,用 Stack 作为最外层容器,将主界面与三个弹窗组件堆叠在一起。Stack 的子元素按声明顺序从底到顶叠加:最底层是主界面 Column,之上依次是添加弹窗、编辑弹窗、删除弹窗。由于弹窗内部有 if (this.visible) 守卫,只有 visible 为 true 的弹窗才会渲染并显示在最上层,其余不渲染——这种"Stack 叠加 + 条件渲染"的弹窗管理方案,使弹窗能覆盖主界面、且多个弹窗互不干扰(虽然实际交互中不会同时显示多个)。
主界面 Column 纵向分三段:顶部 header、中部内容区、底部 TabBar。header 是一个 Row,左侧"🏪 在线商城"标题用 20 号白粗字,右侧"+ 添加"按钮用白底主色字,整体背景为主色 PRIMARY_COLOR,形成一条深青色的应用顶栏。"+ 添加"按钮的 onClick 置 showAddModal = true 弹出添加商品表单。header 的 padding({ left: 16, right: 16, top: 8, bottom: 12 }) 提供内边距,Row().layoutWeight(1) 在标题与按钮间撑开弹性间距。
中部 this.contentArea() 调用前文的路由构建器,根据 currentTab 渲染对应 Tab 内容。内容区未设固定高度,而是靠 layoutWeight 与 TabBar 共同分配 Column 的剩余空间——各 Tab 的最外层 Builder 都用了 layoutWeight(1),确保内容区填满 header 与 TabBar 之间的区域。
底部 TabBar 是一个 Row,横向排列五个 bottomTabItem,背景为白色 CARD_BG。每个 Tab 项 layoutWeight(1) 等分宽度,选中项的图标与文字变主色高亮。TabBar 未设显式高度,由 bottomTabItem 内部的 padding({ top: 8, bottom: 6 }) + 文字行高自然撑开。
三个弹窗的配置体现了"状态驱动显隐"的核心机制。添加弹窗的 initial* 全为空字符串(新建场景无初始值),visible 绑定 showAddModal。编辑弹窗的 initial* 绑定 edit* 状态变量(用户点击编辑时已填充),visible 绑定 showEditModal——两个 ProductFormModal 实例共享同一组件定义,仅入参不同,是组件复用的典范。删除弹窗的 itemName 用一个复杂的三元表达式:this.deleteProductIndex >= 0 && this.deleteProductIndex < this.products.length ? this.products[this.deleteProductIndex].name : '',先做边界检查再取商品名,避免索引越界——这是防御式编程的体现,即使 deleteProductIndex 异常为 -1 或超出长度,也不会崩溃而是显示空字符串。
删除弹窗的 onConfirm 回调包含本应用最关键的数据变更逻辑:先边界检查 deleteProductIndex,然后 this.products.splice(this.deleteProductIndex, 1) 从数组中移除目标商品,紧接着 this.products = [...this.products] 用展开运算符创建新数组并重新赋值。这一步"重新赋值"是关键——在 ArkTS 中,splice 虽然修改了原数组内容,但 @State 监听的是引用而非内容,仅 splice 不一定能触发刷新;通过 this.products = [...this.products] 显式替换引用,确保框架捕获到变化并重新渲染列表。这是 ArkTS 数组状态更新的标准技巧,值得牢记。最后 this.showDeleteModal = false 关闭弹窗。添加与编辑弹窗的 onConfirm 目前仅关闭弹窗(this.showAddModal = false),未执行实际的数据新增/更新——如前文所述,这是示例工程的简化处理,真实场景应在此处 push 新商品或 splice 替换被编辑商品。
整个 build() 的结构清晰展现了 ArkTS 应用的"Stack 装配 + Column 分层 + 状态驱动弹窗"架构范式:主界面与弹窗共存在同一 Stack 中,通过布尔状态控制弹窗显隐,通过枚举状态控制内容区路由,通过数组状态驱动列表渲染。这种架构在中等复杂度的单页面应用中游刃有余,是学习 ArkTS 的优质范本。
二十二、各模块关键技术点横向对比
下表对本应用五个 Tab 页(模块)的关键技术维度进行横向对比,便于一览各模块的设计差异与共性。
| 对比维度 | 全部商品 (ALL) | 购物车 (CART) | 订单 (ORDERS) | 收藏 (FAVORITES) | 我的 (PROFILE) |
|---|---|---|---|---|---|
| 核心功能 | 商品列表展示 + 销售柱状图概览 + 增删改入口 | 购物车条目展示 + 合计计算 + 结算入口 | 订单历史展示 + 状态徽标 | 收藏商品展示 + 取消收藏 | 商家信息卡 + 选项列表 |
| 数据状态 | @State products + @State stats |
@State cartItems |
@State orders |
@State favorites |
读取 products.length 等只读派生值 |
| 列表渲染策略 | 手动展开 10 条 + 边界检查 | 手动展开 5 条 + 边界检查 | 手动展开 6 条 + 边界检查 | 手动展开 7 条 + 边界检查 | 非列表,6 次参数化 Builder 调用 |
| 空状态处理 | 无(始终有数据) | 有(购物车为空 + 引导文案) | 无 | 有(暂无收藏) | 无 |
| 关键子组件 | ProductCard + SalesBarChart |
CartItemView |
OrderItemView |
FavoriteItemView |
profOptionItemBuilder(内联) |
| 状态变更能力 | 通过 ProductCard 回调触发增删改 |
通过 CartItemView 回调 splice 删除 |
不可变(纯展示) | 通过 FavoriteItemView 回调 splice 删除 |
不可变(纯展示) |
| 关键组件装饰器 | @Prop product + 可选回调 |
@Prop item + onRemove |
@Prop order(无回调) |
@Prop item + onRemove |
无独立子组件 |
| 设计模式 | 列表 + 概览图表组合 | 空态/列表态二分 + 结算栏 | 纯展示列表 + 状态色映射 | 空态/列表态二分 | 信息卡 + 选项列表 |
| 交互复杂度 | 高(三个操作按钮 + 弹窗联动) | 中(移除 + 结算) | 低(仅浏览) | 中(取消收藏) | 低(仅浏览) |
| 视觉重点 | 商品卡片信息密度 + 柱状图色彩 | 合计金额红色突出 | 状态徽标三色区分 | 极简两行信息 | 头像 + 选项行 |
| 关联弹窗 | 添加/编辑/删除三个弹窗 | 无 | 无 | 无 | 无 |
| 数据来源 | getProductData() + getStatData() |
getCartData() + push 新增 |
getOrderData() |
getFavoriteData() |
硬编码 + products 派生 |
从表中可以清晰看出,五个 Tab 在"展示型"与"操作型"之间形成梯度:订单页与个人中心页是纯展示型,交互最简;收藏页与购物车页是"展示 + 单一删除"型,中等交互;全部商品页是"展示 + 增删改"型,交互最复杂,且是唯一与三个弹窗联动的模块。状态管理上,products 是被多方读写的核心状态(个人中心读其 length、全部商品页读写其内容),cartItems 与 favorites 各自独立维护,orders 完全只读。这种"核心状态集中、派生状态只读"的分布,是 ArkTS 应用状态分层管理的典型形态。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// 145.ets - 在线商城商品管理 (E-Commerce Product Manager)
// Color: Teal #00695C primary, dark teal accents
// ==================== Interfaces ====================
interface ProductConfig {
id: number;
name: string;
price: number;
category: string;
stock: number;
rating: number;
emoji: string;
}
interface CartItemConfig {
id: number;
productId: number;
name: string;
price: number;
quantity: number;
emoji: string;
}
interface OrderConfig {
id: number;
productName: string;
amount: number;
status: string;
date: string;
emoji: string;
}
interface FavoriteConfig {
id: number;
productId: number;
name: string;
price: number;
emoji: string;
}
interface StatConfig {
category: string;
sales: number;
color: string;
}
interface BarItemConfig {
label: string;
value: number;
maxValue: number;
color: string;
}
interface TabItemConfig {
label: string;
icon: string;
index: number;
}
// ==================== @Observed Data Class ====================
@Observed
class ProductModel {
id: number;
name: string;
price: number;
category: string;
stock: number;
rating: number;
emoji: string;
constructor(id: number, name: string, price: number, category: string, stock: number, rating: number, emoji: string) {
this.id = id;
this.name = name;
this.price = price;
this.category = category;
this.stock = stock;
this.rating = rating;
this.emoji = emoji;
}
}
@Observed
class CartModel {
id: number;
productId: number;
name: string;
price: number;
quantity: number;
emoji: string;
constructor(id: number, productId: number, name: string, price: number, quantity: number, emoji: string) {
this.id = id;
this.productId = productId;
this.name = name;
this.price = price;
this.quantity = quantity;
this.emoji = emoji;
}
}
// ==================== Static Config ====================
const PRIMARY_COLOR: string = '#00695C';
const PRIMARY_LIGHT: string = '#00897B';
const PRIMARY_DARK: string = '#004D40';
const ACCENT_COLOR: string = '#26A69A';
const BG_COLOR: string = '#E0F2F1';
const CARD_BG: string = '#FFFFFF';
const TEXT_PRIMARY: string = '#212121';
const TEXT_SECONDARY: string = '#757575';
const DANGER_COLOR: string = '#D32F2F';
const SUCCESS_COLOR: string = '#388E3C';
const WARNING_COLOR: string = '#F9A825';
// ==================== Tab Enum ====================
enum ShopTab {
ALL = 0,
CART = 1,
ORDERS = 2,
FAVORITES = 3,
PROFILE = 4
}
// ==================== Utility Functions ====================
function formatPriceCNY(price: number): string {
return '¥' + price.toFixed(2);
}
function getStockStatus(stock: number): string {
if (stock > 50) {
return '充足';
}
if (stock > 0) {
return '紧张';
}
return '缺货';
}
function getStockColor(stock: number): string {
if (stock > 50) {
return SUCCESS_COLOR;
}
if (stock > 0) {
return WARNING_COLOR;
}
return DANGER_COLOR;
}
function getCategoryEmoji(category: string): string {
if (category === '电子产品') {
return '📱';
}
if (category === '服装配饰') {
return '👗';
}
if (category === '家居用品') {
return '🏠';
}
if (category === '食品饮料') {
return '🍜';
}
if (category === '图书文具') {
return '📚';
}
return '📦';
}
// ==================== Hardcoded Data ====================
function getProductData(): ProductConfig[] {
const data: ProductConfig[] = [
{ id: 1, name: '智能蓝牙耳机 Pro', price: 299, category: '电子产品', stock: 120, rating: 4.8, emoji: '🎧' },
{ id: 2, name: '轻薄笔记本电脑', price: 5999, category: '电子产品', stock: 35, rating: 4.7, emoji: '💻' },
{ id: 3, name: '无线充电器', price: 89, category: '电子产品', stock: 200, rating: 4.5, emoji: '🔋' },
{ id: 4, name: '4K显示器 27寸', price: 2499, category: '电子产品', stock: 18, rating: 4.6, emoji: '🖥️' },
{ id: 5, name: '机械键盘 RGB', price: 459, category: '电子产品', stock: 67, rating: 4.4, emoji: '⌨️' },
{ id: 6, name: '运动跑鞋 空气垫', price: 599, category: '服装配饰', stock: 150, rating: 4.6, emoji: '👟' },
{ id: 7, name: '纯棉T恤 经典款', price: 89, category: '服装配饰', stock: 500, rating: 4.3, emoji: '👕' },
{ id: 8, name: '时尚双肩背包', price: 259, category: '服装配饰', stock: 89, rating: 4.5, emoji: '🎒' },
{ id: 9, name: '冬季羽绒服', price: 899, category: '服装配饰', stock: 42, rating: 4.7, emoji: '🧥' },
{ id: 10, name: '真皮钱包', price: 199, category: '服装配饰', stock: 110, rating: 4.4, emoji: '👛' },
{ id: 11, name: '北欧风格台灯', price: 189, category: '家居用品', stock: 78, rating: 4.5, emoji: '💡' },
{ id: 12, name: '记忆棉枕头', price: 149, category: '家居用品', stock: 230, rating: 4.6, emoji: '🛏️' },
{ id: 13, name: '不锈钢保温杯', price: 129, category: '家居用品', stock: 340, rating: 4.8, emoji: '☕' },
{ id: 14, name: '智能扫地机器人', price: 1999, category: '家居用品', stock: 55, rating: 4.5, emoji: '🤖' },
{ id: 15, name: '香薰加湿器', price: 169, category: '家居用品', stock: 95, rating: 4.3, emoji: '💨' },
{ id: 16, name: '进口巧克力礼盒', price: 128, category: '食品饮料', stock: 180, rating: 4.7, emoji: '🍫' },
{ id: 17, name: '有机绿茶 特级', price: 89, category: '食品饮料', stock: 260, rating: 4.6, emoji: '🍵' },
{ id: 18, name: '坚果混合装', price: 69, category: '食品饮料', stock: 400, rating: 4.4, emoji: '🥜' },
{ id: 19, name: '进口咖啡豆', price: 159, category: '食品饮料', stock: 70, rating: 4.5, emoji: '☕' },
{ id: 20, name: '蜂蜜柚子茶', price: 49, category: '食品饮料', stock: 320, rating: 4.2, emoji: '🍯' },
{ id: 21, name: '文学小说 精装版', price: 58, category: '图书文具', stock: 190, rating: 4.8, emoji: '📖' },
{ id: 22, name: '彩色记号笔套装', price: 25, category: '图书文具', stock: 600, rating: 4.3, emoji: '🖊️' },
{ id: 23, name: '手帐本 A5', price: 39, category: '图书文具', stock: 280, rating: 4.5, emoji: '📓' },
{ id: 24, name: '编程入门教程', price: 79, category: '图书文具', stock: 140, rating: 4.6, emoji: '📘' },
{ id: 25, name: '艺术画册', price: 128, category: '图书文具', stock: 55, rating: 4.7, emoji: '🎨' },
{ id: 26, name: '平板电脑保护套', price: 89, category: '电子产品', stock: 170, rating: 4.3, emoji: '📱' },
{ id: 27, name: '运动毛巾 速干', price: 35, category: '服装配饰', stock: 380, rating: 4.1, emoji: '🧣' },
{ id: 28, name: '多功能收纳盒', price: 45, category: '家居用品', stock: 210, rating: 4.4, emoji: '📦' },
{ id: 29, name: '即食燕窝礼盒', price: 299, category: '食品饮料', stock: 65, rating: 4.7, emoji: '🥣' },
{ id: 30, name: '彩色便利贴', price: 8, category: '图书文具', stock: 800, rating: 4.2, emoji: '📝' }
];
return data;
}
function getCartData(): CartItemConfig[] {
const data: CartItemConfig[] = [
{ id: 1, productId: 1, name: '智能蓝牙耳机 Pro', price: 299, quantity: 1, emoji: '🎧' },
{ id: 2, productId: 6, name: '运动跑鞋 空气垫', price: 599, quantity: 2, emoji: '👟' },
{ id: 3, productId: 13, name: '不锈钢保温杯', price: 129, quantity: 1, emoji: '☕' },
{ id: 4, productId: 16, name: '进口巧克力礼盒', price: 128, quantity: 3, emoji: '🍫' },
{ id: 5, productId: 21, name: '文学小说 精装版', price: 58, quantity: 1, emoji: '📖' }
];
return data;
}
function getOrderData(): OrderConfig[] {
const data: OrderConfig[] = [
{ id: 1, productName: '智能蓝牙耳机 Pro', amount: 299, status: '已签收', date: '2026-07-20', emoji: '🎧' },
{ id: 2, productName: '机械键盘 RGB', amount: 459, status: '运输中', date: '2026-07-22', emoji: '⌨️' },
{ id: 3, productName: '纯棉T恤 经典款', amount: 89, status: '已签收', date: '2026-07-15', emoji: '👕' },
{ id: 4, productName: '北欧风格台灯', amount: 189, status: '待发货', date: '2026-07-24', emoji: '💡' },
{ id: 5, productName: '坚果混合装', amount: 138, status: '已签收', date: '2026-07-10', emoji: '🥜' },
{ id: 6, productName: '4K显示器 27寸', amount: 2499, status: '运输中', date: '2026-07-23', emoji: '🖥️' }
];
return data;
}
function getFavoriteData(): FavoriteConfig[] {
const data: FavoriteConfig[] = [
{ id: 1, productId: 2, name: '轻薄笔记本电脑', price: 5999, emoji: '💻' },
{ id: 2, productId: 4, name: '4K显示器 27寸', price: 2499, emoji: '🖥️' },
{ id: 3, productId: 12, name: '记忆棉枕头', price: 149, emoji: '🛏️' },
{ id: 4, productId: 14, name: '智能扫地机器人', price: 1999, emoji: '🤖' },
{ id: 5, productId: 20, name: '蜂蜜柚子茶', price: 49, emoji: '🍯' },
{ id: 6, productId: 25, name: '艺术画册', price: 128, emoji: '🎨' },
{ id: 7, productId: 29, name: '即食燕窝礼盒', price: 299, emoji: '🥣' }
];
return data;
}
function getStatData(): StatConfig[] {
const data: StatConfig[] = [
{ category: '电子产品', sales: 85, color: '#00695C' },
{ category: '服装配饰', sales: 62, color: '#00897B' },
{ category: '家居用品', sales: 48, color: '#26A69A' },
{ category: '食品饮料', sales: 73, color: '#4DB6AC' },
{ category: '图书文具', sales: 55, color: '#80CBC4' }
];
return data;
}
// ==================== Product Card Component ====================
@Component
struct ProductCard {
@Prop product: ProductConfig;
onAddToCart?: () => void;
onEdit?: () => void;
onDelete?: () => void;
build() {
Column() {
Row() {
Text(this.product.emoji)
.fontSize(36)
.margin({ right: 12 })
Column() {
Text(this.product.name)
.fontSize(15)
.fontWeight(600)
.fontColor(TEXT_PRIMARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(formatPriceCNY(this.product.price))
.fontSize(16)
.fontWeight(700)
.fontColor(DANGER_COLOR)
Row().layoutWeight(1)
Text(getStockStatus(this.product.stock))
.fontSize(11)
.fontColor(getStockColor(this.product.stock))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(10)
.backgroundColor(getStockColor(this.product.stock) + '20')
}
.width('100%')
.margin({ top: 4 })
Row() {
Text('★ ' + this.product.rating.toString())
.fontSize(12)
.fontColor(WARNING_COLOR)
Row().layoutWeight(1)
Text(this.product.category)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(BG_COLOR)
.borderRadius(4)
}
.width('100%')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Button('加入购物车')
.fontSize(12)
.backgroundColor(PRIMARY_COLOR)
.fontColor(Color.White)
.borderRadius(16)
.height(30)
.padding({ left: 12, right: 12 })
.onClick(() => {
if (this.onAddToCart) {
this.onAddToCart();
}
})
Row().layoutWeight(1)
Button('编辑')
.fontSize(11)
.backgroundColor('#E0E0E0')
.fontColor(TEXT_PRIMARY)
.borderRadius(12)
.height(26)
.padding({ left: 8, right: 8 })
.onClick(() => {
if (this.onEdit) {
this.onEdit();
}
})
Button('删除')
.fontSize(11)
.backgroundColor('#FFEBEE')
.fontColor(DANGER_COLOR)
.borderRadius(12)
.height(26)
.padding({ left: 8, right: 8 })
.margin({ left: 6 })
.onClick(() => {
if (this.onDelete) {
this.onDelete();
}
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(CARD_BG)
.borderRadius(12)
.margin({ bottom: 10 })
}
}
// ==================== Cart Item Component ====================
@Component
struct CartItemView {
@Prop item: CartItemConfig;
onRemove?: () => void;
build() {
Row() {
Text(this.item.emoji)
.fontSize(32)
.margin({ right: 10 })
Column() {
Text(this.item.name)
.fontSize(14)
.fontWeight(500)
.fontColor(TEXT_PRIMARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(formatPriceCNY(this.item.price))
.fontSize(14)
.fontWeight(700)
.fontColor(DANGER_COLOR)
Text(' x' + this.item.quantity.toString())
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ left: 8 })
}
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(formatPriceCNY(this.item.price * this.item.quantity))
.fontSize(15)
.fontWeight(700)
.fontColor(PRIMARY_COLOR)
.margin({ right: 8 })
Button('✕')
.fontSize(14)
.backgroundColor('#FFEBEE')
.fontColor(DANGER_COLOR)
.borderRadius(14)
.width(28)
.height(28)
.onClick(() => {
if (this.onRemove) {
this.onRemove();
}
})
}
.width('100%')
.padding(12)
.backgroundColor(CARD_BG)
.borderRadius(10)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
}
// ==================== Order Item Component ====================
@Component
struct OrderItemView {
@Prop order: OrderConfig;
build() {
Row() {
Text(this.order.emoji)
.fontSize(30)
.margin({ right: 10 })
Column() {
Text(this.order.productName)
.fontSize(14)
.fontWeight(500)
.fontColor(TEXT_PRIMARY)
Row() {
Text(formatPriceCNY(this.order.amount))
.fontSize(14)
.fontWeight(700)
.fontColor(DANGER_COLOR)
Row().layoutWeight(1)
Text(this.order.date)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
}
.width('100%')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(this.order.status)
.fontSize(12)
.fontColor(this.order.status === '已签收' ? SUCCESS_COLOR : (this.order.status === '运输中' ? PRIMARY_COLOR : WARNING_COLOR))
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(this.order.status === '已签收' ? SUCCESS_COLOR + '15' : (this.order.status === '运输中' ? PRIMARY_COLOR + '15' : WARNING_COLOR + '15'))
.borderRadius(10)
}
.width('100%')
.padding(12)
.backgroundColor(CARD_BG)
.borderRadius(10)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
}
// ==================== Favorite Item Component ====================
@Component
struct FavoriteItemView {
@Prop item: FavoriteConfig;
onRemove?: () => void;
build() {
Row() {
Text(this.item.emoji)
.fontSize(28)
.margin({ right: 10 })
Column() {
Text(this.item.name)
.fontSize(14)
.fontWeight(500)
.fontColor(TEXT_PRIMARY)
Text(formatPriceCNY(this.item.price))
.fontSize(13)
.fontColor(DANGER_COLOR)
.fontWeight(600)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Button('取消收藏')
.fontSize(11)
.backgroundColor('#FFF3E0')
.fontColor(WARNING_COLOR)
.borderRadius(12)
.height(28)
.padding({ left: 10, right: 10 })
.onClick(() => {
if (this.onRemove) {
this.onRemove();
}
})
}
.width('100%')
.padding(12)
.backgroundColor(CARD_BG)
.borderRadius(10)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
}
// ==================== Bar Chart Component ====================
@Component
struct SalesBarChart {
@Prop barData: StatConfig[];
build() {
Column() {
Text('📊 各类目销售额统计')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 16 })
Row() {
ForEach(this.barData, (item: StatConfig, index: number) => {
Column() {
Text(item.sales.toString())
.fontSize(10)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
Column()
.width(40)
.height(item.sales * 2)
.backgroundColor(item.color)
.borderRadius({ topLeft: 4, topRight: 4 })
.animation({ duration: 500, curve: Curve.EaseOut })
Text(item.category)
.fontSize(9)
.fontColor(TEXT_SECONDARY)
.margin({ top: 6 })
.maxLines(1)
}
.alignItems(HorizontalAlign.Center)
.margin({ left: 8, right: 8 })
}, (item: StatConfig, index: number) => index.toString())
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(16)
.backgroundColor(CARD_BG)
.borderRadius(12)
.margin({ bottom: 10 })
}
}
// ==================== Add/Edit Modal Component ====================
@Component
struct ProductFormModal {
@Prop title: string;
@Prop initialName: string;
@Prop initialPrice: string;
@Prop initialCategory: string;
@Prop initialStock: string;
@Prop visible: boolean;
onConfirm?: (name: string, price: string, category: string, stock: string) => void;
onCancel?: () => void;
build() {
if (this.visible) {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.5)
.onClick(() => {
if (this.onCancel) {
this.onCancel();
}
})
Column() {
Text(this.title)
.fontSize(18)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 16 })
Column() {
Text('商品名称')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
.alignSelf(ItemAlign.Start)
TextInput({ text: this.initialName, placeholder: '请输入商品名称' })
.fontSize(14)
.height(40)
.backgroundColor(BG_COLOR)
.borderRadius(8)
.padding({ left: 12 })
}
.width('100%')
.margin({ bottom: 12 })
Row() {
Column() {
Text('价格')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
.alignSelf(ItemAlign.Start)
TextInput({ text: this.initialPrice, placeholder: '¥' })
.fontSize(14)
.height(40)
.backgroundColor(BG_COLOR)
.borderRadius(8)
.padding({ left: 12 })
.type(InputType.Number)
}
.layoutWeight(1)
Column() {
Text('库存')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
.alignSelf(ItemAlign.Start)
TextInput({ text: this.initialStock, placeholder: '数量' })
.fontSize(14)
.height(40)
.backgroundColor(BG_COLOR)
.borderRadius(8)
.padding({ left: 12 })
.type(InputType.Number)
}
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.margin({ bottom: 12 })
Column() {
Text('分类')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ bottom: 4 })
.alignSelf(ItemAlign.Start)
TextInput({ text: this.initialCategory, placeholder: '请输入商品分类' })
.fontSize(14)
.height(40)
.backgroundColor(BG_COLOR)
.borderRadius(8)
.padding({ left: 12 })
}
.width('100%')
.margin({ bottom: 20 })
Row() {
Button('取消')
.fontSize(14)
.backgroundColor('#E0E0E0')
.fontColor(TEXT_PRIMARY)
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => {
if (this.onCancel) {
this.onCancel();
}
})
Button('确认')
.fontSize(14)
.backgroundColor(PRIMARY_COLOR)
.fontColor(Color.White)
.borderRadius(20)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
if (this.onConfirm) {
this.onConfirm(this.initialName, this.initialPrice, this.initialCategory, this.initialStock);
}
})
}
.width('100%')
}
.width('85%')
.padding(24)
.backgroundColor(CARD_BG)
.borderRadius(16)
}
.width('100%')
.height('100%')
}
}
}
// ==================== Delete Confirm Modal ====================
@Component
struct DeleteConfirmModal {
@Prop visible: boolean;
@Prop itemName: string;
onConfirm?: () => void;
onCancel?: () => void;
build() {
if (this.visible) {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.5)
.onClick(() => {
if (this.onCancel) {
this.onCancel();
}
})
Column() {
Text('⚠️ 确认删除')
.fontSize(18)
.fontWeight(700)
.fontColor(DANGER_COLOR)
.margin({ bottom: 12 })
Text('确定要删除 "' + this.itemName + '" 吗?此操作不可撤销。')
.fontSize(14)
.fontColor(TEXT_SECONDARY)
.textAlign(TextAlign.Center)
.margin({ bottom: 24 })
Row() {
Button('取消')
.fontSize(14)
.backgroundColor('#E0E0E0')
.fontColor(TEXT_PRIMARY)
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => {
if (this.onCancel) {
this.onCancel();
}
})
Button('删除')
.fontSize(14)
.backgroundColor(DANGER_COLOR)
.fontColor(Color.White)
.borderRadius(20)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
if (this.onConfirm) {
this.onConfirm();
}
})
}
.width('100%')
}
.width('80%')
.padding(24)
.backgroundColor(CARD_BG)
.borderRadius(16)
}
.width('100%')
.height('100%')
}
}
}
// ==================== Main Entry Component ====================
@Entry
@Component
struct ECommerceApp {
@State currentTab: ShopTab = ShopTab.ALL;
@State products: ProductConfig[] = getProductData();
@State cartItems: CartItemConfig[] = getCartData();
@State orders: OrderConfig[] = getOrderData();
@State favorites: FavoriteConfig[] = getFavoriteData();
@State stats: StatConfig[] = getStatData();
@State showAddModal: boolean = false;
@State showEditModal: boolean = false;
@State showDeleteModal: boolean = false;
@State editProductIndex: number = -1;
@State deleteProductIndex: number = -1;
@State editName: string = '';
@State editPrice: string = '';
@State editCategory: string = '';
@State editStock: string = '';
@Builder bottomTabItem(tab: ShopTab, label: string, icon: string) {
Column() {
Text(icon)
.fontSize(22)
.fontColor(this.currentTab === tab ? PRIMARY_COLOR : TEXT_SECONDARY)
Text(label)
.fontSize(11)
.fontColor(this.currentTab === tab ? PRIMARY_COLOR : TEXT_SECONDARY)
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 6 })
.onClick(() => {
this.currentTab = tab;
})
}
@Builder productCardBuilder(product: ProductConfig) {
ProductCard({
product: product,
onAddToCart: () => {
this.cartItems.push({
id: this.cartItems.length + 1,
productId: product.id,
name: product.name,
price: product.price,
quantity: 1,
emoji: product.emoji
} as CartItemConfig);
},
onEdit: () => {
this.editProductIndex = product.id - 1;
this.editName = product.name;
this.editPrice = product.price.toString();
this.editCategory = product.category;
this.editStock = product.stock.toString();
this.showEditModal = true;
},
onDelete: () => {
this.deleteProductIndex = product.id - 1;
this.showDeleteModal = true;
}
})
}
@Builder cartItemBuilder(item: CartItemConfig) {
CartItemView({
item: item,
onRemove: () => {
const idx: number = this.cartItems.findIndex((c: CartItemConfig) => c.id === item.id);
if (idx >= 0) {
this.cartItems.splice(idx, 1);
}
}
})
}
@Builder orderItemBuilder(order: OrderConfig) {
OrderItemView({ order: order })
}
@Builder favoriteItemBuilder(item: FavoriteConfig) {
FavoriteItemView({
item: item,
onRemove: () => {
const idx: number = this.favorites.findIndex((f: FavoriteConfig) => f.id === item.id);
if (idx >= 0) {
this.favorites.splice(idx, 1);
}
}
})
}
@Builder allProductsTab() {
Scroll() {
Column() {
Text('全部商品 (' + this.products.length.toString() + ')')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 12 })
if (this.products.length >= 1) { this.productCardBuilder(this.products[0]) }
if (this.products.length >= 2) { this.productCardBuilder(this.products[1]) }
if (this.products.length >= 3) { this.productCardBuilder(this.products[2]) }
if (this.products.length >= 4) { this.productCardBuilder(this.products[3]) }
if (this.products.length >= 5) { this.productCardBuilder(this.products[4]) }
if (this.products.length >= 6) { this.productCardBuilder(this.products[5]) }
if (this.products.length >= 7) { this.productCardBuilder(this.products[6]) }
if (this.products.length >= 8) { this.productCardBuilder(this.products[7]) }
if (this.products.length >= 9) { this.productCardBuilder(this.products[8]) }
if (this.products.length >= 10) { this.productCardBuilder(this.products[9]) }
SalesBarChart({ barData: this.stats })
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
}
@Builder cartTab() {
Column() {
Text('🛒 购物车 (' + this.cartItems.length.toString() + ')')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 12 })
.alignSelf(ItemAlign.Start)
if (this.cartItems.length === 0) {
Column() {
Text('🛒')
.fontSize(48)
.margin({ bottom: 8 })
Text('购物车为空')
.fontSize(14)
.fontColor(TEXT_SECONDARY)
Text('快去添加商品吧')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column() {
if (this.cartItems.length >= 1) { this.cartItemBuilder(this.cartItems[0]) }
if (this.cartItems.length >= 2) { this.cartItemBuilder(this.cartItems[1]) }
if (this.cartItems.length >= 3) { this.cartItemBuilder(this.cartItems[2]) }
if (this.cartItems.length >= 4) { this.cartItemBuilder(this.cartItems[3]) }
if (this.cartItems.length >= 5) { this.cartItemBuilder(this.cartItems[4]) }
Row() {
Text('合计:')
.fontSize(14)
.fontColor(TEXT_SECONDARY)
Text(formatPriceCNY(this.getCartTotal()))
.fontSize(20)
.fontWeight(700)
.fontColor(DANGER_COLOR)
Row().layoutWeight(1)
Button('去结算')
.fontSize(14)
.backgroundColor(PRIMARY_COLOR)
.fontColor(Color.White)
.borderRadius(20)
.height(40)
.padding({ left: 24, right: 24 })
}
.width('100%')
.padding(16)
.backgroundColor(CARD_BG)
.borderRadius(12)
.margin({ top: 8 })
}
.width('100%')
}
.width('100%')
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
.padding({ left: 16, right: 16, top: 8 })
}
@Builder ordersTab() {
Scroll() {
Column() {
Text('📋 我的订单 (' + this.orders.length.toString() + ')')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 12 })
if (this.orders.length >= 1) { this.orderItemBuilder(this.orders[0]) }
if (this.orders.length >= 2) { this.orderItemBuilder(this.orders[1]) }
if (this.orders.length >= 3) { this.orderItemBuilder(this.orders[2]) }
if (this.orders.length >= 4) { this.orderItemBuilder(this.orders[3]) }
if (this.orders.length >= 5) { this.orderItemBuilder(this.orders[4]) }
if (this.orders.length >= 6) { this.orderItemBuilder(this.orders[5]) }
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
}
@Builder favoritesTab() {
Column() {
Text('❤️ 我的收藏 (' + this.favorites.length.toString() + ')')
.fontSize(16)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 12 })
.alignSelf(ItemAlign.Start)
if (this.favorites.length === 0) {
Column() {
Text('❤️')
.fontSize(48)
.margin({ bottom: 8 })
Text('暂无收藏')
.fontSize(14)
.fontColor(TEXT_SECONDARY)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column() {
if (this.favorites.length >= 1) { this.favoriteItemBuilder(this.favorites[0]) }
if (this.favorites.length >= 2) { this.favoriteItemBuilder(this.favorites[1]) }
if (this.favorites.length >= 3) { this.favoriteItemBuilder(this.favorites[2]) }
if (this.favorites.length >= 4) { this.favoriteItemBuilder(this.favorites[3]) }
if (this.favorites.length >= 5) { this.favoriteItemBuilder(this.favorites[4]) }
if (this.favorites.length >= 6) { this.favoriteItemBuilder(this.favorites[5]) }
if (this.favorites.length >= 7) { this.favoriteItemBuilder(this.favorites[6]) }
}
.width('100%')
}
.width('100%')
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
.padding({ left: 16, right: 16, top: 8 })
}
@Builder profileTab() {
Scroll() {
Column() {
Row() {
Column()
.width(60)
.height(60)
.borderRadius(30)
.backgroundColor(PRIMARY_COLOR)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column() {
Text('商家管理员')
.fontSize(18)
.fontWeight(700)
.fontColor(TEXT_PRIMARY)
Text('shop@example.com')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding(16)
.backgroundColor(CARD_BG)
.borderRadius(12)
.margin({ bottom: 12 })
Column() {
this.profOptionItemBuilder('📊', '销售统计', '查看详情')
this.profOptionItemBuilder('📦', '商品管理', this.products.length.toString() + ' 件商品')
this.profOptionItemBuilder('💰', '本月收入', '¥12,580.00')
this.profOptionItemBuilder('⭐', '店铺评分', '4.7 / 5.0')
this.profOptionItemBuilder('🔔', '消息通知', '3 条未读')
this.profOptionItemBuilder('⚙️', '设置', '')
}
.width('100%')
.backgroundColor(CARD_BG)
.borderRadius(12)
.padding({ top: 4, bottom: 4 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
}
@Builder profOptionItemBuilder(icon: string, title: string, desc: string) {
Row() {
Text(icon)
.fontSize(22)
.margin({ right: 10 })
Column() {
Text(title)
.fontSize(14)
.fontWeight(500)
.fontColor(TEXT_PRIMARY)
if (desc !== '') {
Text(desc)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ top: 1 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(18)
.fontColor(TEXT_SECONDARY)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
private getCartTotal(): number {
let total: number = 0;
for (let i: number = 0; i < this.cartItems.length; i++) {
total += this.cartItems[i].price * this.cartItems[i].quantity;
}
return total;
}
@Builder contentArea() {
if (this.currentTab === ShopTab.ALL) {
this.allProductsTab()
} else if (this.currentTab === ShopTab.CART) {
this.cartTab()
} else if (this.currentTab === ShopTab.ORDERS) {
this.ordersTab()
} else if (this.currentTab === ShopTab.FAVORITES) {
this.favoritesTab()
} else {
this.profileTab()
}
}
build() {
Stack() {
Column() {
Row() {
Text('🏪 在线商城')
.fontSize(20)
.fontWeight(700)
.fontColor(Color.White)
Row().layoutWeight(1)
Button('+ 添加')
.fontSize(12)
.backgroundColor(Color.White)
.fontColor(PRIMARY_COLOR)
.borderRadius(16)
.height(32)
.padding({ left: 14, right: 14 })
.onClick(() => {
this.showAddModal = true;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 12 })
.backgroundColor(PRIMARY_COLOR)
this.contentArea()
Row() {
this.bottomTabItem(ShopTab.ALL, '全部', '🏠')
this.bottomTabItem(ShopTab.CART, '购物车', '🛒')
this.bottomTabItem(ShopTab.ORDERS, '订单', '📋')
this.bottomTabItem(ShopTab.FAVORITES, '收藏', '❤️')
this.bottomTabItem(ShopTab.PROFILE, '我的', '👤')
}
.width('100%')
.backgroundColor(CARD_BG)
}
.width('100%')
.height('100%')
.backgroundColor(BG_COLOR)
ProductFormModal({
title: '添加商品',
initialName: '',
initialPrice: '',
initialCategory: '',
initialStock: '',
visible: this.showAddModal,
onConfirm: (name: string, price: string, category: string, stock: string) => {
this.showAddModal = false;
},
onCancel: () => {
this.showAddModal = false;
}
})
ProductFormModal({
title: '编辑商品',
initialName: this.editName,
initialPrice: this.editPrice,
initialCategory: this.editCategory,
initialStock: this.editStock,
visible: this.showEditModal,
onConfirm: (name: string, price: string, category: string, stock: string) => {
this.showEditModal = false;
},
onCancel: () => {
this.showEditModal = false;
}
})
DeleteConfirmModal({
visible: this.showDeleteModal,
itemName: this.deleteProductIndex >= 0 && this.deleteProductIndex < this.products.length ? this.products[this.deleteProductIndex].name : '',
onConfirm: () => {
if (this.deleteProductIndex >= 0 && this.deleteProductIndex < this.products.length) {
this.products.splice(this.deleteProductIndex, 1);
this.products = [...this.products];
}
this.showDeleteModal = false;
},
onCancel: () => {
this.showDeleteModal = false;
}
})
}
.width('100%')
.height('100%')
}
}
结语:从一个示例工程看 ArkTS 的设计哲学
通览全篇,这个在线商城商品管理示例虽然体量不大,却几乎覆盖了 ArkTS 声明式 UI 开发的所有核心议题。从最底层的 interface 数据契约、@Observed 可观察类,到中层的工具函数与静态数据工厂,再到上层的 @Component 自定义组件、@Builder 构建器方法、@State/@Prop 状态分层,直至顶层的 @Entry 根组件装配与 Stack 弹窗管理,每一层都有清晰的责任边界与协作模式。这种"分层清晰、职责单一、状态集中、数据单向"的架构特征,正是 ArkTS 区别于传统命令式 UI 框架的根本所在。

从工程实践角度,本示例有几处设计尤其值得反复揣摩。其一是"手动展开 + 边界检查"的列表渲染策略——它看似冗长,却规避了 ForEach 的 key 生成陷阱、控制了渲染上限、保证了版本兼容性,是示例工程在稳定性与优雅性之间的务实取舍;在真实工程中,我们应改用 ForEach + 稳定 key + LazyForEach 懒加载,但理解这一策略的动机有助于更深刻地认识 ArkTS 列表渲染的底层机制。其二是"Stack 叠加 + 条件渲染"的弹窗方案——它无需引入路由或对话框框架,仅靠 visible 布尔状态与 if 守卫即可实现模态弹窗的显隐与遮罩,体现了 ArkTS"一切皆状态"的极简哲学;当弹窗数量增多时,可演进为单一 activeModal 枚举状态统一管理。其三是"splice 后 [...arr] 重新赋值"的数组刷新技巧——它揭示了 @State 监听引用而非内容的本质,是每一位 ArkTS 开发者必须掌握的"刷新触发点"。其四是"子组件 @Prop 只读 + 回调上报"的父子通信契约——它保证了数据流的单向可追踪,使应用状态的变化路径始终可从根组件追溯到具体触发源。
从设计哲学角度,本示例折射出 ArkTS 的几个核心理念。首先是"状态即真理,UI 是状态的投影"——开发者只需关心"状态是什么",框架负责"状态如何变成 UI",这与 React 的"UI = f(state)“一脉相承,但 ArkTS 通过语言级装饰器将这一理念内化为语法,比 React 的钩子方案更显原生。其次是"组件化但不碎片化”——@Component 用于独立、可复用、有自己状态的单元,@Builder 用于需访问宿主状态、不引入新层级的片段,二者分工使组件树既清晰又不过度膨胀。再次是"类型安全贯穿始终"——从 interface 到 @Prop 到 @State,每一步都有类型约束,编译器在编码阶段即拦截大量错误,这是 ArkTS 相比动态语言框架的显著优势。最后是"声明式动画与隐式刷新"——SalesBarChart 中的 .animation() 只需声明时长与曲线,框架自动在属性变化时插入过渡,开发者无需手动驱动动画循环,极大降低了动效开发门槛。
当然,本示例也存在若干可优化之处,留作读者进阶练习。其一,ProductFormModal 的 onConfirm 回调传出的仍是 @Prop 初值而非用户输入,应改为 @State 或双向绑定以捕获真实输入。其二,添加与编辑弹窗的 onConfirm 仅关闭弹窗未执行实际数据变更,应补全 push 新商品与 splice 替换商品的逻辑。其三,各 Tab 的空状态处理不统一(购物车与收藏有空态、订单无空态),应统一空态设计规范。其四,SalesBarChart 的柱高用 sales * 2 硬编码缩放,应改为相对最大值的比例缩放以适应动态数据。其五,OrderItemView 中状态色三元判断重复书写两遍,应抽取为 getStatusColor 工具函数。其六,购物车与收藏的 Tab 结构高度对称,可抽象为通用 ItemCollectionTab 构建器消除重复。其七,BarItemConfig 与 TabItemConfig 两个接口定义后未被使用,可移除或投入实际使用。这些优化点并不影响示例的教学价值,反而为读者提供了"在理解基础上的改进实践"的契机。
总而言之,这个在线商城商品管理示例是一份浓缩的 ArkTS 工程微缩样本——它用约一千二百行代码,完整呈现了一个具备多 Tab 导航、列表渲染、状态管理、弹窗交互、数据可视化的中型应用的全貌。逐段拆解这样的示例,远比阅读零散的 API 文档更能建立对框架整体把控感。当我们将每一处 @State、@Prop、@Builder、@Component 的取舍都理解透彻后,再面对真实工程中更复杂的业务需求时,便能从这份"心智地图"中快速定位合适的实现路径。ArkTS 的学习曲线不在于单个 API 的难度,而在于将这些 API 组合成一个有机架构的"全局观"——而这份全局观,正是通过研读此类完整示例方能建立。希望本文的逐段解析,能为你的 HarmonyOS ArkTS 开发之旅提供一份扎实可依的参考。
更多推荐
所有评论(0)