鸿蒙实战:快递包裹管理——状态筛选、详情抽屉与物流时间线(中)




实例:快递包裹管理(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);
}
}
职责分层要点:
refresh()是"全量刷新",负责统计数据和列表;loadList()只负责列表。切换 Tab 时只调loadList()(数据量小,统计不必重算);增删改后调refresh()(统计也要更新)。initSeedData内部有"表非空则跳过"的判空,每次刷新调用都安全,幂等设计让调用方无需关心是否首启。- 所有异步操作包裹在 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:mm。String().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 只收集,业务层负责数据质量。
更多推荐




所有评论(0)