主从嵌套层次分明:ArkUI 订单卡片内嵌明细的鸿蒙界面

实例:订单与明细(Order)|风格:主从嵌套列表

一、设计理念:主从嵌套的信息层级

订单列表的信息密度很高------每个订单要同时展示「订单头(单号、状态、收货人)」「明细(每件商品)」「订单尾(总价、操作)」三层信息。如果平铺成普通列表会非常拥挤;「主从嵌套」(Master-Detail)是标准解法:外层列表项是「主」(订单卡片),内层嵌套「从」(明细子列表)------一个订单卡片内部再渲染它的商品明细列表,形成清晰的主从层次。

页面信息架构:

  1. 标题栏:订单标题 + 标语 + 刷新;
  2. 状态统计条:全部 + 待支付/已支付/已发货/已完成/已取消的订单数(GROUP BY 统计);
  3. 订单列表:主从嵌套卡片------订单头 → 明细区 → 订单尾 → 操作按钮;
  4. 状态流转:待支付 → 支付 → 发货 → 完成(逐级推进),或取消。

二、页面骨架:Column + 筛选条 + List

typescript 复制代码
build() {
  Column() {
    // 标题栏
    Row() { ... }
    // 状态统计条(横向滚动)
    Scroll() { Row() { 全部 + 5 状态 } }
    // 订单列表(主从嵌套)
    List({ space: 10 }) {
      ForEach(this.orders, (o: OrderWithItems) => {
        ListItem() {
          Column() {
            // 订单头
            // 明细子列表
            // 订单尾
            // 操作按钮
          }.padding(14).backgroundColor(Color.White).borderRadius(12)
        }
      }, (o: OrderWithItems) => `${o.order.id}-${o.order.orderNo}`)
    }
    .width('94%').layoutWeight(1).margin({ top: 10 })
    .scrollBar(BarState.Off)
  }
  .width('100%').height('100%').backgroundColor('#F1F4F8')
}

主从嵌套的核心ForEach(this.orders) 外层遍历订单,每个 ListItem 内部再 ForEach(o.items) 遍历明细------List 套 List(更准确说是 ListItem 内嵌 Column + ForEach),这就是嵌套列表。明细区不滚动(随订单卡片整体滚动),用 Column + ForEach 平铺即可。

三、状态统计条:GROUP BY 的可视化

筛选条显示每种状态的订单数,数据来自 statusStats()(GROUP BY status):

typescript 复制代码
Scroll() {
  Row({ space: 8 }) {
    Text(`全部 ${this.totalOrders()}`)
      .fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .borderRadius(16)
      .backgroundColor(this.statusFilter === -1 ? '#111827' : '#FFFFFF')
      .fontColor(this.statusFilter === -1 ? Color.White : '#4B5563')
      .onClick(() => this.switchFilter(-1))
    ForEach(this.statusNames, (name: string, idx: number) => {
      Text(`${name} ${this.stats[String(idx)] ?? 0}`)
        .fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(this.statusFilter === idx ? this.statusColors[idx] : '#FFFFFF')
        .fontColor(this.statusFilter === idx ? Color.White : '#4B5563')
        .onClick(() => this.switchFilter(idx))
    }, (name: string, idx: number) => `${idx}-${name}`)
  }.padding({ left: 16, right: 16, top: 10 })
}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')

this.stats[String(idx)] 的取值 :statusStats 返回 Record<string, number>,键是状态的字符串形式('0'~'4')------String(idx) 转换后取值,?? 0 兜底(没有该状态的订单时显示 0)。状态色板 statusColorsstatusNames 平行数组按索引对应:

索引 状态 颜色
0 待支付 #F59E0B
1 已支付 #3B82F6
2 已发货 #8B5CF6
3 已完成 绿 #059669
4 已取消 #9CA3AF

筛选逻辑(switchFilter)与 8-2 的分类切换同理------筛选变化时重新查询,-1 表示全部:

typescript 复制代码
async switchFilter(f: number): Promise<void> {
  this.statusFilter = f;
  await this.refresh();
}

四、订单卡片:主从三段的完整呈现

每个订单卡片是「订单头 + 明细区 + 订单尾」三段式:

订单头:单号 + 状态标签

typescript 复制代码
Row() {
  Column({ space: 2 }) {
    Text(`订单号 ${o.order.orderNo}`).fontSize(14).fontWeight(FontWeight.Bold)
    Text(`${o.order.customer} · ${this.fmtTime(o.order.createdTime)}`).fontSize(11).fontColor('#999999')
  }.alignItems(HorizontalAlign.Start).layoutWeight(1)
  Text(this.statusNames[o.order.status])
    .fontSize(11).padding({ left: 8, right: 8, top: 3, bottom: 3 })
    .borderRadius(10).backgroundColor(this.statusColors[o.order.status])
    .fontColor(Color.White)
}.width('100%')

订单号加粗(业务标识),收货人 + 时间灰色小字,右侧状态标签用状态色底白字------状态用颜色编码,待支付橙、已完成绿,一眼可辨。

明细区:浅色底内嵌列表

typescript 复制代码
Column() {
  ForEach(o.items, (it: OrderItem) => {
    Row({ space: 8 }) {
      Text(it.productName).fontSize(13).layoutWeight(1)
      Text(`¥${it.price.toFixed(2)} × ${it.quantity}`).fontSize(12).fontColor('#6B7280')
      Text(`¥${it.subtotal.toFixed(2)}`).fontSize(13).fontWeight(FontWeight.Medium)
    }.width('100%').padding({ top: 5, bottom: 5 })
  }, (it: OrderItem) => `${it.id}-${it.productName}`)
}
.width('100%').padding(10).backgroundColor('#F8FAFC').borderRadius(8).margin({ top: 8 })

明细区用 #F8FAFC 浅灰底 + 圆角------视觉上与白色卡片主体区分,形成「主白从灰」的层次。每行:商品名(弹性) + 单价×数量(灰) + 小计(中粗)。

订单尾:总件数 + 总价

typescript 复制代码
Row() {
  Text(`共 ${o.items.length} 件商品`).fontSize(12).fontColor('#6B7280')
  Blank()
  Text(`合计 ¥${o.order.totalAmount.toFixed(2)}`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#DC2626')
}.width('100%').margin({ top: 8 })

总件数灰字,合计红色加粗------总价是订单的视觉落点。

五、操作按钮:状态流转 + 取消 + 删除

卡片底部是操作区,根据当前状态动态渲染按钮:

typescript 复制代码
Row({ space: 8 }) {
  if (o.order.status === 0) {
    Button('取消订单').layoutWeight(1).height(32).backgroundColor('#EEF2F7').fontColor('#EF4444').fontSize(13)
      .onClick(() => this.cancelOrder(o.order.id))
  }
  if (o.order.status < 3) {
    Button(`→ ${this.statusNames[o.order.status + 1]}`).layoutWeight(1).height(32).backgroundColor('#3B82F6').fontSize(13)
      .onClick(() => this.nextStatus(o.order.id, o.order.status))
  }
  Button('删除').height(32).backgroundColor('#FEE2E2').fontColor('#EF4444').fontSize(13)
    .onClick(() => this.deleteOrder(o.order.id))
}
.width('100%').margin({ top: 10 })

状态驱动的按钮渲染

  • 待支付(status=0):显示「取消订单」+「→ 已支付」------支付或取消二选一;
  • 已支付/已发货(1/2):显示「→ 下一状态」推进流转;
  • 已完成/已取消(3/4):只显示「删除」。

动态按钮文案→ ${statusNames[status + 1]}------按钮文字自动显示「下一步状态名」,无需硬编码。这是「状态机驱动的 UI」------按钮集合由状态决定,比固定按钮 + 禁用逻辑更清晰。

状态流转逻辑(nextStatus):

typescript 复制代码
nextStatus(orderId: number, status: number): void {
  if (status >= 3) {
    return;  // 已完成/已取消不可再流转
  }
  const next = status + 1;
  OrderDao.updateStatus(this.context, orderId, next).then(async () => {
    await this.refresh();
    promptAction.showToast({ message: `状态已更新为「${this.statusNames[next]}」` });
  });
}

流转规则 :status < 3 才允许推进(+1),已完成/已取消终止。取消订单单独走 cancelOrder(弹确认框,status → 4)。状态机的边界约束(不可从已完成继续推进)由代码守卫,保证状态流转合法性。

六、删除订单:级联确认

删除订单涉及双表(明细 + 订单),弹确认框:

typescript 复制代码
deleteOrder(id: number): void {
  promptAction.showDialog({
    title: '删除订单',
    message: '删除订单及全部明细,确定吗?',
    buttons: [
      { text: '取消', color: '#808080' },
      { text: '删除', color: '#EF4444' },
    ],
  }).then((res: promptAction.ShowDialogSuccessResponse) => {
    if (res.index === 1) {
      OrderDao.deleteOrder(this.context, id).then(async () => {
        await this.refresh();
        promptAction.showToast({ message: '🗑 已删除' });
      });
    }
  });
}

提示语明确说明「删除订单及全部明细」------让用户知道这是级联删除。

七、刷新与统计

typescript 复制代码
async refresh(): Promise<void> {
  try {
    await OrderDao.initSeedData(this.context);
    this.stats = await OrderDao.statusStats(this.context);
    this.orders = await OrderDao.queryWithItems(this.context, this.statusFilter === -1 ? undefined : this.statusFilter);
  } catch (e) {
    promptAction.showToast({ message: `加载失败: ${e}` });
  }
}

queryWithItems 带状态过滤 ------筛选条选中某状态时,只加载该状态的订单(含明细)。undefined 表示不过滤(全部),与 DAO 的 status?: number 可选参数配合(9-3 文章详解)。

八、UI 风格要素一览

风格项 取值 说明
页面背景 #F1F4F8 浅冷灰 衬托白卡
订单卡 白底圆角 12 主容器
明细区 #F8FAFC 浅灰底圆角 8 主从层次
状态标签 状态色底白字 颜色编码状态
总价 #DC2626 16fp Bold 视觉落点
主色 #3B82F6 流转按钮

九、文章小结

本篇文章完成了实例 9 的 UI 层:主从嵌套列表(订单头 → 明细区 → 订单尾)+ 状态统计条 + 状态驱动的动态按钮。核心是「一主多从」的嵌套渲染(ListItem 内嵌 ForEach 明细)、状态颜色编码、以及状态机驱动的按钮集合------这三个模式构成了订单管理类页面的标准骨架。

下一篇(9-3)深入数据层,讲解事务下单、JOIN 联表查询与状态流转的实现------那是本实例的技术核心。

十、展开式明细:点击订单卡片的交互增强

严格意义上,上面的明细区是「常显」的------订单卡片内部永远平铺全部明细。当订单商品较多时卡片会显得冗长,更高级的交互是展开式明细:默认只显示订单头 + 合计,点击卡片头部才展开明细区。ArkUI 用状态变量 + transition 即可实现:

typescript 复制代码
@State expandedId: number = -1;  // 当前展开的订单 id,-1 表示全部收起

// 卡片头部:点击切换展开/收起
Row() {
  Text(`订单号 ${o.order.orderNo}`).fontSize(14).fontWeight(FontWeight.Bold)
  Text(this.statusNames[o.order.status])
    .fontSize(11).backgroundColor(this.statusColors[o.order.status]).fontColor(Color.White)
    .borderRadius(10).padding({ left: 8, right: 8, top: 3, bottom: 3 })
}.width('100%').onClick(() => {
  this.expandedId = this.expandedId === o.order.id ? -1 : o.order.id;  // 再点收起
})

// 明细区:展开时才渲染,带出现/消失动画
if (this.expandedId === o.order.id) {
  Column() {
    ForEach(o.items, (it: OrderItem) => {
      Row({ space: 8 }) {
        Text(it.productName).fontSize(13).layoutWeight(1)
        Text(`¥${it.subtotal.toFixed(2)}`).fontSize(13).fontWeight(FontWeight.Medium)
      }.width('100%').padding({ top: 5, bottom: 5 })
    }, (it: OrderItem) => `${it.id}-${it.productName}`)
  }
  .width('100%').padding(10).backgroundColor('#F8FAFC').borderRadius(8).margin({ top: 8 })
  .transition(TransitionEffect.asymmetric(
    TransitionEffect.opacity(0).combine(TransitionEffect.translate({ y: -8 })),
    TransitionEffect.opacity(1)
  ))
}

关键 ArkTS 细节

  • expandedId 单值互斥 :同时只展开一个订单,再次点击同一订单收起(三元切换)------比布尔数组 expandedFlags[] 更省内存,且天然保证互斥;
  • if 条件渲染 + transition :明细区用 if 包裹,配合 .transition() 在组件插入/移除时播放动画------展开淡入下滑,收起淡出上滑,默认时长约 300ms;
  • animation 与 transition 的分工.animation() 驱动属性变化(如高度、透明度渐变),.transition() 驱动组件的出现/消失------展开收起是「组件存在性」变化,用 transition 更合适。

十一、状态标签配色与合计金额的视觉规范

状态标签与合计金额是本页面的两个视觉重点,规范汇总:

元素 规则 取值
状态标签底色 状态色(橙/蓝/紫/绿/灰) 见第二节色板
标签文字 11fp 白字,padding 8/3,圆角 10 小标签不喧宾夺主
合计金额 16fp Bold 红 #DC2626 页面最大字号之一
合计前缀 「合计 ¥」12fp 灰字 #6B7280 区分金额与文案

ArkTS 细节

  • 金额统一 toFixed(2) 保留两位小数,避免浮点误差显示(如 19.9000000001);
  • 状态色板用平行数组 statusColors: string[]statusNames 按索引对齐------新增状态只需改数组,渲染代码零改动
  • #DC2626 红色语义统一:仅「合计」与「删除」使用,页面红色只表达「金额 / 危险操作」,避免颜色歧义。

FAQ

Q1:展开式明细会影响 List 的复用性能吗?

A:会,但可控。展开/收起触发 if 分支重建明细区,开销与明细条数成正比(本实例每单 ≤3 条,完全无感);若单条明细很多,可改为固定高度 + 高度动画方案。

Q2:状态标签为什么不用图标?

A:颜色 + 文字已足够区分五种状态,图标会增加色板维护成本。状态流转后标签颜色自动切换,用户靠「颜色记忆」即可定位状态,这正是颜色编码的价值。

Q3:transition 动画在 List 里失效怎么办?

A:ListItem 的子组件 transition 需要确保 if 分支位于 ListItem 内部(本页正是如此),并给 List 留出动画空间(space: 10);若仍失效,检查是否误用了 .animation() 去驱动条件渲染------条件渲染必须配 .transition()

相关推荐
还不秃顶的计科生3 小时前
具身智能论文学习8:Octo: An Open-Source Generalist Robot Policy
人工智能·深度学习·学习·机器学习·语言模型·vla·vlm
woshihuanglaoshi3 小时前
事务加持防超卖:ArkTS 在鸿蒙里玩转 TRANSACTION 出入库
学习·华为·harmonyos
世人万千丶4 小时前
去重插入与超限淘汰:ArkTS 实现鸿蒙搜索历史的 LIMIT 艺术
运维·服务器·学习·华为·harmonyos·鸿蒙
2601_949950635 小时前
在线练题更方便,用练题簿打造属于自己的移动题库
学习·考研·刷题·小程序推荐
贾伟康5 小时前
【知律|08】HarmonyOS ArkTS 语音阅读实战:管理法条朗读状态和中断恢复
生命周期·harmonyos·arkts·语音合成·arkui
woshihuanglaoshi5 小时前
十二商品二十流水:鸿蒙进销存种子数据装满预警看板
学习·华为·harmonyos
MartinYeung56 小时前
[论文学习]ProAct:针对LLM越狱的主动防禦框架
网络·学习·安全
weixin_431600447 小时前
NestJS 入门(10):日志——为什么常用 Winston?
后端·学习·日志·nest.js·winston
何事误红尘7 小时前
ZYNQ学习ARM裸机笔记():VITIS
学习