在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

实例:快递包裹管理(Parcel Manager)|技术:状态 Tab、ForEach 列表、弹窗、时间线 UI、状态流转交互

一、页面架构总览

上一篇完成了数据层,本篇基于 ParcelDao 搭建完整交互界面。页面结构分为四层:

Stack                      // 层叠容器:列表 + 悬浮按钮 + 两个弹层
├── Column                 // 主内容
│   ├── 标题栏             // 标题 + 公司概览文案 + 刷新
│   ├── 状态筛选 Tab       // 全部/待揽收/运输中/派送中/已签收/异常
│   └── 包裹列表           // Scroll + ForEach 卡片
├── 悬浮新增按钮(+)
├── 新增包裹弹窗           // 表单:单号/公司/物品/寄件人/收件人
└── 详情抽屉               // 包裹信息 + 状态流转按钮组 + 物流时间线 + 删除

页面状态量设计如下:

@State parcels: Parcel[] = [];        // 当前筛选下的包裹列表
@State filter: number = -1;           // -1 全部 / 0~4 对应状态
@State statusCounts: number[] = [0,0,0,0,0];  // 五种状态的件数
@State companyStats: string = '';     // 公司概览文案
@State formVisible: boolean = false;  // 新增弹窗开关
@State detailVisible: boolean = false;// 详情抽屉开关
@State current: Parcel | null = null; // 当前查看的包裹
@State traces: ParcelTrace[] = [];    // 当前包裹的时间线
// 表单输入
@State fNo/fCompany/fGoods/fSender/fReceiver: string = '';

所有 @State 都是驱动 UI 渲染的数据。非状态量(如 DAO 单例)不需要装饰器。

二、数据加载:refresh 的职责分层

aboutToAppear(): void {
  this.refresh();
}

async refresh(): Promise<void> {
  try {
    await ParcelDao.initSeedData(this.context);      // 1. 首启填充种子
    this.statusCounts = await ParcelDao.statusSummary(this.context); // 2. 状态统计
    await this.loadList();                            // 3. 当前列表
    const stats = await ParcelDao.companyStats(this.context);
    this.companyStats = stats.map((s) => `${s.company} ${s.count}件/签收${s.signedCount}`).join(' · ');
  } catch (e) {
    promptAction.showToast({ message: `加载失败: ${e}` });
  }
}

async loadList(): Promise<void> {
  if (this.filter === -1) {
    this.parcels = await ParcelDao.queryAll(this.context);
  } else {
    this.parcels = await ParcelDao.queryByStatus(this.context, this.filter);
  }
}

职责分层要点

  1. refresh() 是"全量刷新",负责统计数据和列表;loadList() 只负责列表。切换 Tab 时只调 loadList()(数据量小,统计不必重算);增删改后调 refresh()(统计也要更新)。
  2. initSeedData 内部有"表非空则跳过"的判空,每次刷新调用都安全,幂等设计让调用方无需关心是否首启。
  3. 所有异步操作包裹在 try/catch,失败用 toast 提示,避免未捕获 Promise 异常导致页面卡死。

三、状态筛选 Tab 实现

3.1 全部 Tab + 动态状态 Tab

Scroll() {
  Row({ space: 8 }) {
    // 「全部」固定项
    Text('全部')
      .fontSize(13)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .backgroundColor(this.filter === -1 ? '#3B82F6' : '#EEF2F7')
      .fontColor(this.filter === -1 ? Color.White : '#555555')
      .borderRadius(16)
      .onClick(() => { this.filter = -1; this.loadList(); })

    // 五种状态:由 PARCEL_STATUS 数组驱动,天然支持未来扩展
    ForEach(PARCEL_STATUS.map((name, idx) => ({ name: name, idx: idx })), (item) => {
      Text(`${item.name}${this.statusCounts[item.idx] > 0 ? `(${this.statusCounts[item.idx]})` : ''}`)
        .fontSize(13)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .backgroundColor(this.filter === item.idx ? '#3B82F6' : '#EEF2F7')
        .fontColor(this.filter === item.idx ? Color.White : '#555555')
        .borderRadius(16)
        .onClick(() => { this.filter = item.idx; this.loadList(); })
    }, (item) => `${item.idx}-${item.name}`)
  }
  .padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)   // 横向滚动:状态多时不被挤压
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 10 })

关键设计

  • Tab 由数据驱动而非硬编码PARCEL_STATUS.map((name, idx) => ({name, idx})) 把常量数组映射成 {name, idx} 对象数组,ForEach 渲染。未来加"退回件"状态,只需改常量数组,UI 自动多出一个 Tab——UI 与数据模型解耦
  • 数字角标${item.name}(${this.statusCounts[item.idx]}) 显示该状态件数,件数为 0 时不显示括号,避免视觉噪音。角标数据来自 statusSummary() 一条 SQL 的结果数组。
  • 选中态高亮filter === item.idx ? 蓝色底白字 : 浅灰底深字,ArkTS 三元表达式在链式属性中非常常见。
  • ForEach 第三个参数 keyGenerator(item) => \item.idx−{item.idx}-item.idx{item.name}`` 提供稳定 key,ArkUI 据此做最小化 diff 更新。

3.2 为什么用 Scroll 横向而非 Row 直接排

状态 Tab 有 6 项,在窄屏设备上可能超出宽度。包裹 Scroll + scrollable(ScrollDirection.Horizontal) 让 Tab 可横向滑动,同时 scrollBar(BarState.Off) 隐藏滚动条保持美观。这是"一维内容不确定宽度"的标准解法——与此前实例中纵向列表用 Scroll 包裹是同一思路的横向版本。

四、包裹列表卡片

Scroll() {
  Column({ space: 10 }) {
    if (this.parcels.length === 0) {
      Text('暂无包裹').fontSize(14).fontColor('#999999').margin({ top: 40 })
    }
    ForEach(this.parcels, (p: Parcel) => {
      Column() {
        // 第一行:公司 + 单号 + 状态徽标
        Row({ space: 8 }) {
          Text(p.company).fontSize(15).fontWeight(FontWeight.Bold)
          Text(p.trackingNo).fontSize(13).fontColor('#999999')
          Blank()
          Text(PARCEL_STATUS[p.status])
            .fontSize(12).fontColor(Color.White)
            .backgroundColor(this.statusColor(p.status))
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(10)
        }.width('100%')

        // 第二行:物品 + 寄收件人
        Row({ space: 12 }) {
          Text(p.goods || '未填写物品').fontSize(14)
          Text(`${p.sender || '?'}${p.receiver || '?'}`).fontSize(12).fontColor('#999999')
        }.width('100%').margin({ top: 8 })

        // 第三行:创建时间
        Text(`创建 ${this.fmtTime(p.createdTime)}`)
          .fontSize(11).fontColor('#9CA3AF').width('100%').margin({ top: 6 })
      }
      .width('94%').padding(14)
      .backgroundColor(Color.White).borderRadius(12)
      .onClick(() => this.openDetail(p))
    }, (p: Parcel) => `${p.id}-${p.trackingNo}-${p.status}`)
  }
  .padding({ top: 12, bottom: 90 })
}
.width('100%').layoutWeight(1)
.scrollBar(BarState.Off)

卡片设计解析

  • 状态徽标statusColor(p.status) 根据状态返回颜色——待揽收灰、运输中蓝、派送中橙、已签收绿、异常红,五种颜色语义化区分,用户扫一眼即知状态。这是移动端列表最常见的"状态可视化"手法。
  • Blank() 弹性占位:第一行用 Blank() 把状态徽标推到最右,等价于 justifyContent(SpaceBetween),代码更简洁。
  • 空态提示parcels.length === 0 时显示"暂无包裹",避免空白页。空态、加载态、错误态三态处理是生产级页面的基本要求。
  • keyGenerator${p.id}-${p.trackingNo}-${p.status} 包含状态,切 Tab 时列表内容变化,key 变化触发正确刷新。

五、悬浮新增按钮与表单弹窗

5.1 悬浮按钮:Stack 层叠定位

Text('+')
  .width(52).height(52).borderRadius(26)
  .backgroundColor('#3B82F6').fontColor(Color.White).fontSize(28)
  .textAlign(TextAlign.Center)
  .margin({ right: 20, bottom: 24 })
  .shadow({ radius: 8, color: 'rgba(59,130,246,0.4)', offsetY: 3 })
  .position({ x: '84%', y: '82%' })
  .onClick(() => { this.formVisible = true; })

position({ x: '84%', y: '82%' }) 相对 Stack 容器右下角定位,配合 shadow 营造"悬浮"质感。FAB(Floating Action Button)是移动端高频操作的标准形态,比顶部按钮更利于拇指触达。

5.2 新增弹窗

if (this.formVisible) {
  Column() {
    Text('📦 添加包裹').fontSize(18).fontWeight(FontWeight.Bold)
    TextInput({ placeholder: '快递单号 *', text: this.fNo })
      .margin({ top: 12 }).onChange((v: string) => this.fNo = v)
    TextInput({ placeholder: '物流公司 *(如 顺丰速运)', text: this.fCompany })
      .margin({ top: 8 }).onChange((v: string) => this.fCompany = v)
    TextInput({ placeholder: '物品描述', text: this.fGoods })
      .margin({ top: 8 }).onChange((v: string) => this.fGoods = v)
    Row({ space: 8 }) {
      TextInput({ placeholder: '寄件人', text: this.fSender }).layoutWeight(1)
        .onChange((v: string) => this.fSender = v)
      TextInput({ placeholder: '收件人', text: this.fReceiver }).layoutWeight(1)
        .onChange((v: string) => this.fReceiver = v)
    }.margin({ top: 8 })
    Row({ space: 8 }) {
      Button('取消').layoutWeight(1).backgroundColor('#EEF2F7').fontColor('#555555')
        .onClick(() => this.formVisible = false)
      Button('保存').layoutWeight(1).backgroundColor('#3B82F6')
        .onClick(() => this.onSave())
    }.margin({ top: 16 })
  }
  .padding(20).borderRadius(16).backgroundColor(Color.White).width('88%')
  .position({ x: '6%', y: '12%' })
}

表单实现细节

  • 必填校验在保存方法onSave() 里先 if (!this.fNo.trim() || !this.fCompany.trim()) toast 提示,再组装实体。校验放业务层而非 UI 层,UI 只负责收集输入。
  • 双输入框并排:寄件人/收件人用 Row + layoutWeight(1) 各占一半,窄屏上两个短字段并排比上下叠放更紧凑。
  • placeholder 带星号'快递单号 *' 提示必填项,比单独 label 更省空间。

5.3 保存逻辑

async onSave(): Promise<void> {
  if (!this.fNo.trim() || !this.fCompany.trim()) {
    promptAction.showToast({ message: '请填写单号和物流公司' });
    return;
  }
  const p: Parcel = {
    id: 0, trackingNo: this.fNo.trim(), company: this.fCompany.trim(),
    goods: this.fGoods.trim(), sender: this.fSender.trim(),
    receiver: this.fReceiver.trim(),
    status: 0, remark: '', createdTime: Date.now(), signedTime: 0,
  };
  try {
    await ParcelDao.insert(this.context, p);
    this.formVisible = false;
    // 清空表单,避免下次打开残留上次输入
    this.fNo = ''; this.fCompany = ''; this.fGoods = '';
    this.fSender = ''; this.fReceiver = '';
    await this.refresh();
    promptAction.showToast({ message: '✅ 包裹已添加' });
  } catch (e) {
    promptAction.showToast({ message: `保存失败: ${e}` });
  }
}

注意新包裹 status: 0(待揽收)是硬编码的——新增的包裹必然从起点开始,这体现了状态机的初始态约定。保存成功后清空表单、关闭弹窗、全量刷新、toast 反馈,形成完整闭环。

六、详情抽屉:状态流转 + 物流时间线 ★核心 UI

6.1 打开详情

async openDetail(p: Parcel): Promise<void> {
  this.current = p;
  this.traces = await ParcelDao.queryTrace(this.context, p.id);
  this.detailVisible = true;
}

先设置 current 再异步拉时间线,最后打开抽屉。时间线加载完成后 @State 更新,抽屉内 ForEach 自动渲染。

6.2 状态流转按钮组

Row({ space: 6 }) {
  ForEach(PARCEL_STATUS.map((name, idx) => ({ name: name, idx: idx })), (item) => {
    Text(item.name)
      .fontSize(12)
      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      .backgroundColor(this.current!.status === item.idx ? this.statusColor(item.idx) : '#EEF2F7')
      .fontColor(this.current!.status === item.idx ? Color.White : '#555555')
      .borderRadius(8)
      .onClick(() => this.changeStatus(item.idx))
  }, (item) => `s-${item.idx}`)
}.width('100%').margin({ top: 12 })

交互语义:点击任意状态按钮即可"流转到该状态",当前状态高亮。真实物流系统会限制流转方向(如不能从"已签收"回到"待揽收"),本实例为教学演示保持自由流转,读者可自行加方向校验(如 if (newStatus < this.current.status && newStatus !== 4) 提示'状态不可回退')。

6.3 状态流转方法:确认对话框 + 事务写入

async changeStatus(newStatus: number): Promise<void> {
  if (this.current === null) {
    return;
  }
  promptAction.showDialog({
    title: `更新为「${PARCEL_STATUS[newStatus]}`,
    message: '请输入该节点的说明文字(可留空)',
    buttons: [
      { text: '取消', color: '#808080' },
      { text: '确定', color: '#3B82F6' },
    ],
  }).then(async (res: promptAction.ShowDialogSuccessResponse) => {
    if (res.index !== 1) {
      return;  // 点取消不处理
    }
    try {
      await ParcelDao.updateStatus(this.context, this.current!.id, newStatus, '状态更新');
      await this.refresh();          // 列表与统计一起更新
      await this.openDetail(this.current!);  // 重新拉时间线,抽屉内时间线追加新节点
      promptAction.showToast({ message: '✅ 状态已更新' });
    } catch (e) {
      promptAction.showToast({ message: `更新失败: ${e}` });
    }
  });
}

为什么用 showDialog 而不是直接流转? 状态变更是一次"会产生历史记录"的操作,给用户一个确认环节是负责任的设计——误触按钮不会产生脏数据。showDialog 返回 Promise,res.index === 1 表示用户点了"确定"。

流转后的三重刷新refresh() 更新列表与统计(主表 status 变了),openDetail() 重新查询时间线(从表多了节点),toast 反馈。抽屉保持打开状态,用户能立刻看到时间线里新出现的节点——反馈闭环是交互设计的灵魂

6.4 物流时间线渲染

Scroll() {
  Column() {
    ForEach(this.traces, (t: ParcelTrace, idx: number) => {
      Row({ space: 10 }) {
        // 左侧:节点圆点 + 连接竖线
        Column() {
          Text(this.traces.length - 1 - idx === 0 ? '●' : '○')
            .fontSize(12).fontColor(this.statusColor(t.status))
          if (idx < this.traces.length - 1) {
            Divider().vertical(true).height(28).color('#E5E7EB')
          }
        }.height(40)
        // 右侧:状态 + 说明 + 时间
        Column({ space: 2 }) {
          Text(PARCEL_STATUS[t.status]).fontSize(14).fontWeight(FontWeight.Medium)
          Text(t.note).fontSize(12).fontColor('#666666')
          Text(this.fmtTime(t.traceTime)).fontSize(11).fontColor('#9CA3AF')
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .alignItems(VerticalAlign.Top)
      .width('100%')
    }, (t: ParcelTrace) => `${t.id}-${t.traceTime}`)
  }
  .width('100%').padding({ top: 6, bottom: 10 })
}
.layoutWeight(1).width('100%').scrollBar(BarState.Off)

时间线 UI 的经典实现,逐点拆解

① 圆点状态表达:最新节点(traces.length - 1 - idx === 0,即列表最后一个)用实心 ,历史节点用空心 。实心圆点 + 状态色,让用户一眼定位"当前在哪个阶段"。

② 连接竖线Divider().vertical(true).height(28) 在圆点下方画竖线,把各节点串成"轨迹"。注意 if (idx < traces.length - 1)——最后一个节点不画竖线,否则多出一条悬空的线。这是时间线组件最常见的边界处理。

③ 时间线方向:DAO 里 orderByAsc('trace_time'),最早在上、最新在下,与真实物流 App(顺丰/菜鸟)的滚动轨迹一致。实心点在视觉底部,符合"最新状态在最下方"的阅读习惯。

④ 左侧列固定高度Column().height(40) 让圆点+竖线组合稳定占位,右侧文本换行时左侧不错位,这是时间线布局的关键——左右两列必须垂直对齐,用固定高度容器 + alignItems(VerticalAlign.Top) 实现。

6.5 删除确认

onDelete(): void {
  if (this.current === null) {
    return;
  }
  promptAction.showDialog({
    title: '删除包裹',
    message: `确定删除 ${this.current.trackingNo} 及其全部物流记录吗?`,
    buttons: [
      { text: '取消', color: '#808080' },
      { text: '删除', color: '#EF4444' },
    ],
  }).then(async (res) => {
    if (res.index === 1) {
      await ParcelDao.delete(this.context, this.current!.id);
      this.detailVisible = false;
      await this.refresh();
      promptAction.showToast({ message: '🗑 已删除' });
    }
  });
}

删除提示明确告知"连同物流记录一起删",因为 DAO 的 delete 是先删从表再删主表——用户需要知情。删除按钮用红色系(#FEE2E2 底 + #DC2626 字)传达危险性。

七、工具方法与样式常量

private statusColor(s: number): string {
  const map: string[] = ['#9CA3AF', '#3B82F6', '#F59E0B', '#10B981', '#EF4444'];
  return map[s] ?? '#9CA3AF';
}

private fmtTime(ts: number): string {
  if (ts === 0) {
    return '—';
  }
  const d = new Date(ts);
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
  • statusColor:状态 → 颜色的映射表,与 PARCEL_STATUS 数组下标对齐。?? '#9CA3AF' 兜底未来可能出现的越界状态。
  • fmtTime:时间戳 → YYYY-MM-DD HH:mmString().padStart(2, '0') 补零是时间格式化的标准写法;ts === 0 返回 ‘—’,处理 signed_time 的"未签收"语义。

八、交互流程图

启动页面 ──► aboutToAppear ──► refresh()
                                   │
                                   ├─ initSeedData(首启填充)
                                   ├─ statusSummary(状态统计)
                                   ├─ queryAll / queryByStatus(列表)
                                   └─ companyStats(公司概览)
                                        │
                        ┌───────────────┴───────────────┐
                        │                               │
                  切换 Tab ──► loadList()          点击卡片 ──► openDetail
                        │                               │
                        │                          queryTrace(拉时间线)
                        │                               │
                  点 + ──► 弹窗表单 ──► onSave          │
                        │     ├─ 校验必填                ├─ 显示抽屉
                        │     ├─ insert(+初始节点)      │
                        │     └─ refresh                ├─ 点状态按钮 ──► showDialog
                        │                               │     └─ updateStatus(事务)
                        │                               │         ├─ refresh
                        │                               │         └─ openDetail(时间线追加)
                        │                               │
                        │                               └─ 删除 ──► showDialog ──► delete
                        │                                            └─ refresh
                        └──────────────────────────────────────────┘

九、UI 与交互技术要点对照表

技术点 实现方式 生产价值
状态 Tab PARCEL_STATUS.map + ForEach + 数字角标 UI 由数据驱动,扩展状态零改动
列表卡片 ForEach + Blank + 状态徽标 信息密度与可读性平衡
悬浮按钮 Stack + position + shadow FAB 拇指友好,高频操作前置
表单弹窗 if 条件渲染 + TextInput 双向绑定 无第三方弹窗依赖,轻量可控
详情抽屉 条件渲染 + 圆点竖线时间线 物流轨迹可视化,最新节点实心高亮
状态流转 showDialog 确认 + 事务写入 + 三重刷新 防误触 + 数据一致 + 反馈闭环
删除 红色危险按钮 + 明确提示 防误删,用户知情
时间格式化 padStart 补零 + 0 值占位 统一显示规范

十、本篇小结

本篇完成了快递包裹管理的全部界面:状态筛选 Tab 让"我的包裹有哪些"一屏可答卡片列表让状态一眼可辨详情抽屉的时间线让"包裹走到哪了"可视化为轨迹状态流转用确认框 + 事务 + 三重刷新构成完整闭环。UI 层的每个设计(数据驱动 Tab、实心圆点标记最新节点、删除红色警示)都直接服务业务语义,而非堆砌组件。

下一篇《完整代码与运行效果》将给出 ParcelDao 与 ParcelPage 的全量源码解读、运行效果演示、以及从本实例抽象出的"状态机 + 事件时间线"通用模板,可直接复用到工单、订单、审批等业务。


十一、UI 实现深度扩展

1. 为什么用 Stack 而不用绝对定位容器

本页面有"列表 + 悬浮按钮 + 弹窗 + 抽屉"四种层级,用 Stack(层叠容器)天然表达"浮动在内容之上"的语义。position 的百分比坐标 {x:'84%', y:'82%'} 是相对 Stack 的百分比,适配不同屏幕宽度。若用 Column 普通布局,悬浮按钮会被滚动列表推走,无法保持固定。

2. @State 与渲染性能

ArkUI 的 @State 变更会触发该组件子树重渲染。本页面数据量级小(几十条包裹),ForEach 全量刷新无感知。若未来包裹上千,可考虑:

  • ForEach 的 keyGenerator 提供稳定 key(已实现),让 ArkUI 做最小 diff;
  • 分页加载(limitAs + 滚动到底部加载下一页),复用 08 商品分页实例的成果;
  • 列表项用 LazyForEach 惰性渲染,只构建可见项。

3. 弹窗与抽屉:if 条件渲染 vs 系统组件

本实例用 if (this.formVisible) { Column... } 自绘弹窗,优点是零依赖、样式完全可控;缺点是遮挡层(半透明遮罩)需要自绘。系统提供的 promptAction.showDialog 适合简单确认,CustomDialogController 适合复杂表单——读者可根据复杂度选择。本实例教学目标是条件渲染 + 层叠定位,故采用最直白的方式。

4. 时间线组件复用性

圆点 + 竖线 + 文本三列结构是时间线的通用骨架,可直接抽取为 @Component TimelineItem 复用。抽取的时机:同一结构出现两处以上。当前仅详情抽屉一处,保持内联更清晰——不要为一次使用提前抽象

5. FAQ

Q1:切换 Tab 为什么不重新拉统计?
A:统计(statusCounts)只依赖主表全部数据,Tab 切换不影响统计值,只影响列表内容。若每次都重查统计,会有轻微浪费;数据量增大后再考虑缓存。当前实现"切 Tab 只 loadList,增删改才 refresh"是最小开销方案。

Q2:实心圆点怎么判断"最新"?
A:traces.length - 1 - idx === 0 等价于 idx === traces.length - 1,即 ForEach 最后一个元素。时间线是 orderByAsc(最新在末尾),所以末尾即最新。若改排序方向,此判断要同步调整。

Q3:状态流转按钮为什么不禁止非法跳转?
A:教学演示保留自由流转,展示"无论怎么跳,时间线都如实记录"。生产环境建议加方向校验,例如派送中不能直接回到待揽收(除非异常件)。实现很简单:if (newStatus < currentStatus && newStatus !== 4) toast('状态不可回退')

Q4:抽屉高度为什么 62%?
A:62% 高度容纳"信息行 + 状态按钮 + 时间线滚动区 + 删除按钮",太长会遮挡列表失去上下文,太短时间线放不下。百分比而非固定值是为了适配不同屏幕。可用 onAreaChange 动态测量,但教学场景固定值足够。

Q5:表单输入为什么要 trim?
A:用户可能输入前后空格(如" 顺丰 "),trim 后存储保证查询 like 匹配与展示整洁。校验与清洗都放在 onSave 入口,是"边界校验"原则——UI 只收集,业务层负责数据质量。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐