前言

订单列表页看上去只是把几张卡片排出来,真正难的地方其实在于:同一份订单数据要同时服务筛选栏、状态标签、商品列表、金额汇总和底部操作按钮。只要这几个区域没有围绕同一个状态模型来组织,页面就会很快变成一堆条件分支的拼接。

这个 HarmonyOS 示例比较适合拿来讲“电商业务页怎么把展示逻辑和业务语义绑在一起”。它没有引入复杂网络层,也没有拆出一堆组件,但已经把订单页最核心的几件事做全了:订单状态切换、列表筛选、商品明细展示,以及不同状态下的动作映射。

这个页面真正要解决的,不是“把订单显示出来”

很多人第一次写订单页,会把注意力放在卡片布局上,比如店铺名放哪、价格放哪、按钮摆哪。布局当然重要,但这不是业务页最容易出问题的地方。订单页的核心其实是:一条订单记录进入页面后,应该被解释成什么样的用户可见信息

以这个案例为例,一条订单至少要回答下面几件事:

  • 当前处于什么状态。
  • 应该出现在哪个筛选标签下。
  • 底部应该给用户什么操作。
  • 有没有额外的补充动作,比如查看物流。
  • 商品数量、总价和创建时间怎么组合成一张完整卡片。

这也是为什么示例里虽然只有 ordersactiveTab 两个核心状态,但页面并不单薄。它把复杂度放在了“解释数据”这件事上,而不是盲目扩状态。

先看状态模型:为什么这页只需要两个核心状态

这个页面最值得学的地方之一,就是状态数量很克制。

@State orders: OrderItem_ksld[] = [ ... ]
@State activeTab: string = 'all'

A hand-drawn doodle illustration on pure white pap

orders 是唯一主数据源,里面不仅有订单 id、店铺名和总价,还顺手把商品明细、状态、创建时间这些展示层必需的信息全部带上。这样做的好处是,列表渲染时不用再到别处查数据,也不需要临时拼接多个对象。

activeTab 则承担了当前筛选上下文。它的意义并不只是“高亮哪个 tab”,而是告诉整个页面:现在用户想看哪一类订单。只要这个值变化,列表内容和 tab 视觉状态都会一起变化。

这种设计非常像真实业务系统里常说的“单一主数据源 + 少量视图状态”。它的优点很直接:

  • 容易排查问题,列表不对就先看 ordersactiveTab
  • 扩展成本低,后面要加搜索、售后过滤或者按时间排序,都能在现有结构上叠加。
  • 不容易出现多份状态彼此不同步的情况。

statusConfig 不是装饰,它是状态翻译层

订单状态通常不会直接拿原始字段给用户看。源码里用一张 statusConfig 表,把内部状态值翻译成可见文案和颜色:

private statusConfig: Record<string, StatusConfig_oskv> = {
  'pending_pay': { label: '待付款', color: '#FF3B30' },
  'pending_ship': { label: '待发货', color: '#FF9500' },
  'shipped': { label: '运输中', color: '#007AFF' },
  'delivered': { label: '待评价', color: '#34C759' },
  'reviewed': { label: '已完成', color: '#888888' },
  'refund': { label: '退款中', color: '#FF6B35' },
}

这类映射表在业务代码里很常见,但很多教程不会特意强调它的重要性。实际上,它解决的是“同一状态要在多个地方保持一致”的问题。

如果没有这层配置,你很可能会在不同卡片、不同组件里手写很多类似的判断:

  • if status === 'shipped' 就显示运输中
  • if status === 'shipped' 就用蓝色
  • if status === 'refund' 就用橙色

A hand-drawn doodle illustration on pure white pap

判断一多,后期一改状态名或者视觉规范,就会变成全文件找分支。把这些东西集中进配置表,本质上是在给展示层做解耦。后面哪怕接入后端枚举、国际化文本,改动面也会小很多。

getFilteredOrders() 负责列表可见范围,而不是改订单数据

订单页里最容易写混的一个点,是“筛选”到底是在改数据,还是在决定显示哪些数据。这个示例处理得很清楚:

getFilteredOrders(): OrderItem_ksld[] {
  if (this.activeTab === 'all') return this.orders
  return this.orders.filter((o: OrderItem_ksld) => o.status === this.activeTab)
}

这个函数没有去修改 orders,而是根据 activeTab 返回一个当前应该展示的结果集。这个思路非常重要,因为筛选本质上是“视图投影”,不是“重写原始数据”。

这样做有三个直接好处:

  • 切换 tab 不会破坏原始订单数据。
  • 同一个 orders 可以被不同筛选条件重复使用。
  • 后面叠加搜索、排序、售后筛选时,逻辑可以自然继续往这个函数里加,而不是到处改 orders

如果你以后做的是更复杂的电商页,这个函数通常还会继续演化成组合筛选入口,比如状态 + 时间范围 + 关键词的联合过滤。示例虽然简单,但骨架是对的。

操作按钮映射,是这页最像真实业务的地方

订单页真正体现“业务味”的地方,不是商品卡片,而是底部操作区。因为不同状态下用户能做的事不一样,这意味着按钮文案、按钮数量、按钮样式都可能变化。

源码里先把主按钮文案抽成函数:

getActionButton(status: string): string {
  const btnMap: Record<string, string> = {
    'pending_pay': '立即付款',
    'pending_ship': '取消订单',
    'shipped': '确认收货',
    'delivered': '去评价',
    'reviewed': '再次购买',
    'refund': '查看详情',
  }
  return btnMap[status] ?? '查看详情'
}

A hand-drawn doodle illustration on pure white pap

这个函数的价值不只是省代码,而是把“订单状态决定用户动作”明确建模出来。你看这几组映射就会发现,它们其实对应的是一条订单生命周期:

  • 下单未支付时,引导完成付款。
  • 已支付待发货时,常见动作变成取消订单。
  • 已发货时,主动作转向确认收货。
  • 已收货未评价时,再把用户导向评价闭环。

这比单纯说“根据状态显示不同按钮”更重要,因为它体现的是产品流程。教程如果只停留在 UI 层,很难帮读者建立这种业务意识。

另外,源码还额外处理了一个很真实的细节:只有 shipped 状态时,才会额外展示“查看物流”按钮。说明这页不是简单的一主按钮模型,而是开始区分“主动作”和“补充动作”。这在正式项目里经常会继续演化成操作集合配置。

商品明细区为什么要放在同一张订单卡里

在视觉上,这页最占空间的部分是商品列表。但从数据组织角度看,更关键的是:products 被嵌进单条订单里,而不是单独平铺。这样一来,订单和商品天然形成了父子关系。

好处很明显:

  • 渲染时可以直接在订单卡内部 ForEach(order.products)
  • 汇总区可以直接基于当前订单的商品数量做 reduce
  • 一张卡片内部就能同时表达店铺、商品、金额和操作,不需要跨区域拼装。

这也解释了为什么订单卡片读起来很完整。它不是“先渲染订单,再到别处找商品”,而是围绕一条订单,把用户关心的信息一次性交代完。

从交互链路看,这页其实是一条很完整的闭环

把源码顺一遍,你会发现这页虽然没有写点击后的复杂事件,但交互闭环已经很完整了:

  1. 顶部 tab 先确定用户当前看的订单范围。
  2. getFilteredOrders() 根据 tab 产出列表内容。
  3. 每张订单卡再根据状态展示文案颜色和按钮动作。
  4. 特定状态追加补充动作,比如查看物流。
  5. 用户从列表中继续进入付款、收货、评价或售后后续流程。

这类页面的价值就在这里。它不是做一个“静态设计稿还原”,而是在有限代码里把状态、列表、动作三者串起来了。

如果把它扩成正式项目,优先改哪些地方

这个案例已经适合教学和原型演示,但如果你要把它往真实项目推,我建议优先做下面几件事:

  • 把状态配置和按钮映射抽成独立常量,避免页面文件继续变重。
  • 给订单项补充更多业务字段,比如支付方式、物流单号、退款进度。
  • 把订单卡、商品行、操作区拆成子组件,降低 build() 的阅读负担。
  • 给按钮点击补上真实行为,比如确认收货后的状态推进、取消订单后的列表刷新。
  • 增加空列表、加载中、接口失败这几类正式项目必备状态。

尤其是最后一点,很多教程文章都容易忽略。但订单页一旦接远程接口,空态和错误态往往比正常态更需要提前设计。

完整代码

下面保留案例完整代码,方便你直接对照学习、复制和重构。

// 网购订单中心: 网购订单列表 - 订单状态流转、商品卡片、评价入口

interface OrderProduct {
  name: string
  spec: string
  price: number
  quantity: number
  imageDesc: string
}

interface OrderItem_ksld {
  id: string
  shopName: string
  status: string  // 'pending_pay' | 'pending_ship' | 'shipped' | 'delivered' | 'reviewed' | 'refund'
  products: OrderProduct[]
  totalPrice: number
  freight: number
  createTime: string
  estimatedTime: string
}

interface TabOption {
  key: string
  label: string
}
interface  StatusConfig_oskv {
  label: string
  color: string
}

@Entry
@Component
struct EcommerceOrderCenter {
  @State orders: OrderItem_ksld[] = [
    {
      id: 'ORD20260601001',
      shopName: '苹果官方旗舰店',
      status: 'shipped',
      products: [
        { name: 'iPhone 16 Pro', spec: '黑色 256G', price: 8999, quantity: 1, imageDesc: '📱' },
        { name: 'MagSafe 保护壳', spec: '透明', price: 199, quantity: 1, imageDesc: '🔲' },
      ],
      totalPrice: 9198,
      freight: 0,
      createTime: '2026-06-01 14:30',
      estimatedTime: '2026-06-07 预计送达'
    },
    {
      id: 'ORD20260602002',
      shopName: '耐克官方旗舰店',
      status: 'delivered',
      products: [
        { name: 'Air Max 2026', spec: '白色 42码', price: 1299, quantity: 1, imageDesc: '👟' },
      ],
      totalPrice: 1299,
      freight: 0,
      createTime: '2026-05-28 10:15',
      estimatedTime: '已送达'
    },
    {
      id: 'ORD20260603003',
      shopName: '京东自营',
      status: 'pending_pay',
      products: [
        { name: 'MacBook Pro 14', spec: 'M4 512G', price: 14999, quantity: 1, imageDesc: '💻' },
      ],
      totalPrice: 14999,
      freight: 0,
      createTime: '2026-06-06 09:00',
      estimatedTime: '待付款'
    },
    {
      id: 'ORD20260604004',
      shopName: '小米官方',
      status: 'reviewed',
      products: [
        { name: '小米手环 9', spec: '黑色', price: 299, quantity: 1, imageDesc: '⌚' },
        { name: '充电器 67W', spec: '白色', price: 99, quantity: 2, imageDesc: '🔌' },
      ],
      totalPrice: 497,
      freight: 0,
      createTime: '2026-05-20 16:45',
      estimatedTime: '已评价'
    },
    {
      id: 'ORD20260605005',
      shopName: '网易严选',
      status: 'refund',
      products: [
        { name: '纯棉T恤', spec: '白色 L码', price: 129, quantity: 2, imageDesc: '👕' },
      ],
      totalPrice: 258,
      freight: 0,
      createTime: '2026-05-15 11:20',
      estimatedTime: '退款中'
    },
  ]
  @State activeTab: string = 'all'

  private tabs: TabOption[] = [
    { key: 'all', label: '全部' },
    { key: 'pending_pay', label: '待付款' },
    { key: 'pending_ship', label: '待发货' },
    { key: 'shipped', label: '待收货' },
    { key: 'delivered', label: '待评价' },
    { key: 'refund', label: '退款' },
  ]

  private statusConfig: Record<string, StatusConfig_oskv> = {
    'pending_pay': { label: '待付款', color: '#FF3B30' },
    'pending_ship': { label: '待发货', color: '#FF9500' },
    'shipped': { label: '运输中', color: '#007AFF' },
    'delivered': { label: '待评价', color: '#34C759' },
    'reviewed': { label: '已完成', color: '#888888' },
    'refund': { label: '退款中', color: '#FF6B35' },
  }

  getFilteredOrders(): OrderItem_ksld[] {
    if (this.activeTab === 'all') return this.orders
    return this.orders.filter((o: OrderItem_ksld) => o.status === this.activeTab)
  }

  getActionButton(status: string): string {
    const btnMap: Record<string, string> = {
      'pending_pay': '立即付款',
      'pending_ship': '取消订单',
      'shipped': '确认收货',
      'delivered': '去评价',
      'reviewed': '再次购买',
      'refund': '查看详情',
    }
    return btnMap[status] ?? '查看详情'
  }

  build() {
    Column() {
      // 顶部
      Row() {
        Text('我的订单').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
        Blank()
        Text('🔍').fontSize(20)
      }
      .padding({ left: 20, right: 20, top: 20, bottom: 0 })

      // 状态筛选Tab
      Scroll() {
        Row({ space: 0 }) {
          ForEach(this.tabs, (tab: TabOption) => {
            Column() {
              Text(tab.label)
                .fontSize(13)
                .fontColor(this.activeTab === tab.key ? '#FF6B35' : '#666666')
                .fontWeight(this.activeTab === tab.key ? FontWeight.Bold : FontWeight.Normal)
                .padding({ top: 14, bottom: 14, left: 12, right: 12 })
              Divider()
                .strokeWidth(2)
                .color(this.activeTab === tab.key ? '#FF6B35' : 'transparent')
            }
            .onClick(() => { this.activeTab = tab.key })
          })
        }
        .padding({ left: 8, right: 8 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 12 })

      // 订单列表
      List({ space: 12 }) {
        ForEach(this.getFilteredOrders(), (order: OrderItem_ksld) => {
          ListItem() {
            Column() {
              // 店铺行
              Row() {
                Text('🏪').fontSize(14)
                Text(order.shopName)
                  .fontSize(14).fontWeight(FontWeight.Medium).fontColor('#1A1A1A').margin({ left: 6 })
                Blank()
                Text(this.statusConfig[order.status]?.label ?? order.status)
                  .fontSize(13)
                  .fontColor(this.statusConfig[order.status]?.color ?? '#888888')
                  .fontWeight(FontWeight.Medium)
              }
              .width('100%')
              .padding({ bottom: 10 })
              .border({ width: { bottom: 0.5 }, color: { bottom: '#F5F5F5' } })

              // 商品列表
              ForEach(order.products, (p: OrderProduct) => {
                Row() {
                  Text(p.imageDesc)
                    .fontSize(36)
                    .width(70).height(70)
                    .textAlign(TextAlign.Center)
                    .backgroundColor('#F8F8F8')
                    .borderRadius(8)

                  Column() {
                    Text(p.name)
                      .fontSize(13).fontColor('#333333').maxLines(2)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                    Text(p.spec)
                      .fontSize(11).fontColor('#999999')
                      .backgroundColor('#F5F5F5').borderRadius(4)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .margin({ top: 4 })
                    Row() {
                      Text(`¥${p.price.toFixed(2)}`)
                        .fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FF3B30')
                      Blank()
                      Text(`x${p.quantity}`).fontSize(12).fontColor('#999999')
                    }
                    .width('100%')
                    .margin({ top: 6 })
                  }
                  .layoutWeight(1)
                  .margin({ left: 12 })
                  .alignItems(HorizontalAlign.Start)
                }
                .width('100%')
                .padding({ top: 10, bottom: 10 })
                .border({ width: { bottom: 0.5 }, color: { bottom: '#F5F5F5' } })
              })

              // 合计
              Row() {
                Text(`${order.products.reduce((s: number, p: OrderProduct) => s + p.quantity, 0)}`)
                  .fontSize(12).fontColor('#888888')
                Blank()
                Text('实付:')
                  .fontSize(12).fontColor('#888888')
                Text(`¥${order.totalPrice.toFixed(2)}`)
                  .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF3B30')
              }
              .width('100%')
              .padding({ top: 10 })

              // 操作按钮
              Row() {
                Text(order.createTime).fontSize(11).fontColor('#BBBBBB')
                Blank()
                if (order.status === 'shipped') {
                  Button('查看物流')
                    .fontSize(12).fontColor('#555555')
                    .backgroundColor('#F5F5F5').borderRadius(16).height(32)
                    .padding({ left: 12, right: 12 })
                    .margin({ right: 8 })
                }
                Button(this.getActionButton(order.status))
                  .fontSize(12).fontColor('#FF6B35')
                  .backgroundColor('transparent')
                  .border({ width: 1, color: '#FF6B35' })
                  .borderRadius(16).height(32)
                  .padding({ left: 12, right: 12 })
              }
              .width('100%')
              .margin({ top: 10 })
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(14)
          }
        })
      }
      .layoutWeight(1)
      .padding({ left: 16, right: 16 })
      .scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
    .backgroundColor('#F8F8F8')
  }
}

最后总结

这篇订单页案例最值得带走的,不是某个按钮怎么画,而是它把订单状态当成页面的中心语义来处理。只要你把“状态翻译”“列表筛选”“动作映射”这三层关系吃透,后面再写售后页、物流页、交易记录页,思路都会顺很多。

Logo

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

更多推荐