鸿蒙原生应用 ArkUI 声明式实战:不引图表库手写兑换记录页积分消耗趋势图
App 16「校园积分商城」兑换 Tab(Func2Tab),是兑换订单的历史列表页。整页用 Header + 4 个胶囊筛选 Tab(全部/待发货/已发货/已完成)+ 3 栏统计概览 +积分消耗趋势条形图(总计 3,700)+ 6 条兑换记录时间线(每条记录按状态显示不同操作:待发货→取消兑换/修改地址、已发货→查看物流/运输中、已完成→再次兑换/✓已完成,每条含数量/消耗积分/收货地址)。本篇基于
16-points-mall/entry/src/main/ets/pages/Func2Tab.ets(约 260 行)逐段拆解,附 4 张实机截图。
一、整体结构:筛选 + 统计 + 趋势 + 时间线
Func2Tab 与 App 14/15 记录页同构(Header + TabBar 固定、Scroll 内容流),趋势图替代占比图:
build() { Column() { this.Header() this.TabBar() Scroll() { Column({ space: 16 }) { this.StatsOverview() this.PointsTrend() this.SectionTitle('兑换记录') this.RecordTimeline() } .width('100%') .padding({ left: D.pad, right: D.pad, top: 16, bottom: D.pad + this.safeBottom + 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width('100%').height('100%').backgroundColor(C.bg) }4 个模块:StatsOverview(3 栏统计)→PointsTrend(积分消耗趋势——本页特色)→ SectionTitle('兑换记录') → RecordTimeline(6 条时间线)。
与 App 14 预约页的差异:App 14 是"统计 → 时间线"两段,App 15 是"统计 → 占比 → 时间线"三段,App 16 是"统计 →趋势→ 时间线"三段——"占比"(构成分析)换成"趋势"(时间变化)——App 15 回答"分类的都是什么",App 16 回答"积分怎么花掉的"。
项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub
二、Header + TabBar:4 个胶囊筛选
Header 单行标题"兑换记录",TabBar 是 4 个胶囊(全部/待发货/已发货/已完成):
@Builder TabBar() { Row() { ForEach(this.tabs, (t: FilterTab) => { Text(t.label) .fontSize(13) .fontColor(this.activeTab === t.id ? C.primary : C.textSub) .fontWeight(this.activeTab === t.id ? FontWeight.Bold : FontWeight.Normal) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .backgroundColor(this.activeTab === t.id ? C.primarySoft : 'transparent') .borderRadius(16) .onClick(() => { this.activeTab = t.id; }) }, (t: FilterTab) => t.id.toString()) } .width('100%') .padding({ left: D.pad, right: D.pad, top: 12, bottom: 12 }) .backgroundColor(C.card) }4 个 Tab:全部/待发货/已发货/已完成——物流状态视角(区别于 App 14 的"待确认/已完成/已取消"业务状态、App 15 的"已分类/待处理/错误分类"处理状态)。"状态分类跟随业务":App 14 关注"预约确认"、App 15 关注"分类处理"、App 16 关注"物流发货"——每个 App 的状态机不同。
id = status 值(0 全部/1 待发货/2 已发货/3 已完成),filtered()一行过滤(r.status === this.activeTab)——"Tab id = 状态值"模板第 3 次复用。
三、StatsOverview:3 栏统计
StatsOverview 与 App 14/15 同构(3 栏数字 + 标签):
private stats: StatItem[] = [ { value: '12', label: '总兑换' }, { value: '8', label: '已发货' }, { value: '2', label: '待发货' } ];总兑换 12 / 已发货 8 / 待发货 2——与首页"已兑换 12"一致 ✓。"已发货 8 + 待发货 2 = 10 ≠ 12"——其余 2 条是"已完成"(8+2+2=12 才对,demo 数据"已完成"未计入统计)——小瑕疵:统计只列了"总/发货中/待发货"三项,没把"已完成"单独列出(已完成 4 条在时间线里有,但统计口径里"已发货 8"可能包含已完成?口径不清晰——不过 demo 场景无伤大雅)。
3 栏同色(C.text),"颜色留给状态"。
四、PointsTrend:积分消耗趋势(本页特色)
PointsTrend 是 7 天积分消耗的条形图:
@Builder PointsTrend() { Column({ space: 12 }) { Row() { Text('积分消耗趋势').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text('总计 3,700').fontSize(12).fontColor(C.primary) } .width('100%') Row({ space: 4 }) { ForEach([30, 55, 40, 70, 85, 60, 45], (v: number, idx: number) => { Column({ space: 6 }) { Stack({ alignContent: Alignment.Bottom }) { Column() .width(18).height(v) .linearGradient({ angle: 180, colors: [[C.primary, 0.0], [C.accent, 1.0]] }) .borderRadius(4) } .width(24).height(60) Text(['一', '二', '三', '四', '五', '六', '日'][idx]).fontSize(10).fontColor(C.textDim) } .layoutWeight(1) .alignItems(HorizontalAlign.Center) }, (v: number, idx: number) => idx.toString()) } .width('100%') } .width('100%') .padding(14) .backgroundColor(C.card).borderRadius(D.rMd) .border({ width: 1, color: C.stroke }) }与前几代 WeeklyChart 的 3 个差异:
1. 内联数组:ForEach([30, 55, 40, 70, 85, 60, 45], ...)直接写数组字面量,没有private barData——**"一次性数据内联"**的取舍(数据只在 Chart 用、不需要复用,内联更短;但可读性略差)。
2. 内联星期:['一', '二', '三', '四', '五', '六', '日'][idx]数组索引取星期——7 个字符串直接写在 ForEach 里。"索引取数"代替数据字段(BarItem 没有 day 字段了,靠 idx 取星期)。
3. 高度 60 而非 90:.width(24).height(60)柱子容器更矮——"趋势图更紧凑"(记录页空间紧张,图表让位)。
"总计 3,700"的数学:30+55+40+70+85+60+45 = 385 ≠ 3700——"总计 3,700"与柱子数据不匹配(柱子是"消耗强度"30-85,总计却写 3,700 积分)!这是 demo 的数据瑕疵——柱子是"强度百分比"(30-85),总计是"真实积分"(3,700),两个口径。App 15 也有同类问题(趋势 68% vs 总计 47 次)。"图表数据与汇总口径不一致"是 demo 高频坑,值得反复提醒:要么柱子=真实值(30 积分/55 积分…总计 385),要么总计=柱子之和。
图表本身(Stack 底部对齐 + 渐变玫红)与前几代同构。
五、RecordTimeline:三态物流记录
RecordTimeline 是 6 条兑换记录的时间线,每条含数量、消耗积分、收货地址、状态操作:
@Builder RecordTimeline() { Column({ space: 0 }) { ForEach(this.filtered(), (r: Record, idx: number) => { Column({ space: 0 }) { if (idx > 0) { Row().width(2).height(12).backgroundColor(C.stroke).margin({ left: 18 }) } Row({ space: 12 }) { Column() { Row() { Text(r.emoji).fontSize(18) } .width(36).height(36) .backgroundColor(C.primarySoft).borderRadius(18) .justifyContent(FlexAlign.Center) } .width(36) Column({ space: 8 }) { Row({ space: 8 }) { Text(r.product).fontSize(14).fontWeight(FontWeight.Medium).fontColor(C.text) Text('x' + r.qty).fontSize(12).fontColor(C.textDim) Blank() Text(r.statusText) .fontSize(11).fontColor(r.statusColor) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(C.primarySoft).borderRadius(8) } .width('100%') Row({ space: 6 }) { Text('消耗').fontSize(11).fontColor(C.textDim) Text('-' + r.points + ' 积分').fontSize(12).fontColor(C.primary).fontWeight(FontWeight.Medium) Text('· ' + r.date + ' ' + r.time).fontSize(12).fontColor(C.textDim) } Text('收货: ' + r.addr).fontSize(11).fontColor(C.textDim) if (r.status === 1) { Row({ space: 8 }) { Button('取消兑换') .fontSize(12).fontColor(C.textSub).backgroundColor(C.cardSoft) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '取消兑换' }); }) Button('修改地址') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '修改地址' }); }) } .width('100%') } if (r.status === 2) { Row({ space: 8 }) { Button('查看物流') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '查看物流' }); }) Text('运输中').fontSize(11).fontColor(C.primary) } .width('100%') } if (r.status === 3) { Row({ space: 8 }) { Button('再次兑换') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '再次兑换 ' + r.product }); }) Text('✓ 已完成').fontSize(11).fontColor(C.ok) } .width('100%') } } .alignItems(HorizontalAlign.Start).layoutWeight(1) } .width('100%') .padding({ left: 14, right: 14, top: 14, bottom: 14 }) .backgroundColor(C.card).borderRadius(D.rMd) .border({ width: 1, color: C.stroke }) } .width('100%') }, (r: Record) => r.id.toString()) } .width('100%') }5.1 记录头:商品 + 数量 + 状态标签
Row({ space: 8 }) { Text(r.product).fontSize(14).fontWeight(FontWeight.Medium).fontColor(C.text) Text('x' + r.qty).fontSize(12).fontColor(C.textDim) Blank() Text(r.statusText) .fontSize(11).fontColor(r.statusColor) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(C.primarySoft).borderRadius(8) }"商品名 + x数量"(x2表示 2 件)——电商订单的标准数量表示。状态标签(待发货橙/已发货主色/已完成绿)——注意"已发货"用C.primary玫红("进行中"用主色,与 App 15 的"已发货用主色"一致——**"进行中状态用主色"**是本系列记录页的通用约定:待处理用橙(警示)、进行中用主色(品牌)、完成用绿(成功))。
5.2 消耗积分 + 收货地址
Row({ space: 6 }) { Text('消耗').fontSize(11).fontColor(C.textDim) Text('-' + r.points + ' 积分').fontSize(12).fontColor(C.primary).fontWeight(FontWeight.Medium) Text('· ' + r.date + ' ' + r.time).fontSize(12).fontColor(C.textDim) } Text('收货: ' + r.addr).fontSize(11).fontColor(C.textDim)"消耗 -800 积分"用负号 + 主色——"支出用负号"是财务显示的标准(收入 +、支出 −)。收货地址单独一行(收货: 7 号楼 305)——电商订单必须显示收货地址(App 14/15 记录没有地址维度,App 16 有——"记录字段反映业务必须")。
5.3 三态操作区
if (r.status === 1) { Row({ space: 8 }) { Button('取消兑换') .fontSize(12).fontColor(C.textSub).backgroundColor(C.cardSoft) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '取消兑换' }); }) Button('修改地址') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '修改地址' }); }) } .width('100%') } if (r.status === 2) { Row({ space: 8 }) { Button('查看物流') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '查看物流' }); }) Text('运输中').fontSize(11).fontColor(C.primary) } .width('100%') } if (r.status === 3) { Row({ space: 8 }) { Button('再次兑换') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '再次兑换 ' + r.product }); }) Text('✓ 已完成').fontSize(11).fontColor(C.ok) } .width('100%') }3 个状态分支,3 组操作:
| 状态 | 主按钮 | 次操作 | 语义 |
|---|---|---|---|
| 待发货(1) | 修改地址(主色) | 取消兑换(灰底) | 未发货可改可退 |
| 已发货(2) | 查看物流(主色) | "运输中"文字 | 路上可跟踪 |
| 已完成(3) | 再次兑换(主色) | "✓ 已完成"绿字 | 可回购 |
关键设计——"待发货双按钮":取消兑换(灰底次级)+ 修改地址(主色主按钮)——"发货前可反悔可修改"(物流状态机:未发货 = 可操作窗口)。已发货单按钮:查看物流(主色)+ "运输中"状态文字。已完成:再次兑换 + "✓ 已完成"。
对比 App 14/15 的三态:
| App | 状态 1 | 状态 2 | 状态 3 |
|---|---|---|---|
| 14 预约 | 待确认→取消/详情 | 已完成→再次预约 | 已取消→无操作 |
| 15 分类 | 已分类→✓正确 | 待处理→立即分类 | 错误→重新分类(红) |
| 16 兑换 | 待发货→取消/改址 | 已发货→查看物流 | 已完成→再次兑换 |
"状态 → 操作"映射每个 App 不同,但模式相同:状态机驱动 UI 操作。App 16 的"待发货改地址"是电商特有的操作(物流未出可改收货信息)。
5.4 数据与筛选
6 条记录:蓝牙耳机 800 分(待发货)、保温杯 500 分(已发货)、学习笔记本 200 分 x2(已完成)、游戏手柄 600 分(已完成)、台灯 400 分(已发货)、校园背包 1200 分(已完成)——三态全覆盖 + 消耗积分梯度(200-1200)。filtered()真筛选(App 14 同款)。
记录里的地址:7 号楼 305(宿舍)/A 栋 302(教学楼)/校门口快递站(自取)——与商品页 AddressSelector 的 3 个地址一致✓(跨页数据一致)。
六、物流状态机
App 16 兑换页背后的物流状态机:
下单 → 待发货 → 已发货 → 已完成 ↘ 取消兑换(退款)- 待发货(status=1):订单已生成、未发货——可取消(退积分)、可改地址
- 已发货(status=2):物流在途——可查看物流、不可取消
- 已完成(status=3):签收完成——可再次兑换
"发货是分水岭":发货前可反悔(取消/改址),发货后只能跟踪/签收——**"操作权限随状态收紧"**是订单状态机的核心规则(淘宝下单后可退、发货后只能退货运费险,同理)。
"再次兑换"是复购逻辑:用户用过觉得好 → 一键再换(App 14"再次预约"同款)——"已完成 → 再次购买"是电商留存的经典设计。
七、时间线 vs 列表的取舍
App 16 用时间线(圆点 + 竖线连接)而非独立卡片——因为兑换记录有时间先后(08-14 → 08-02 按日期倒序),时间线强化"消费历史"的顺序感(与 App 11 快递取件、App 14 预约记录同理)。
"记录间竖线"(if (idx > 0) Row().width(2).height(12))高度固定 12vp——记录卡片高度不固定(待发货双按钮 vs 已完成单按钮高度不同),竖线可能"对不齐"——真实项目应让竖线layoutWeight(1)跟随内容(前几篇已反复提醒的"断线"问题)。
八、与 App 14/15 记录页的对比总结
| 维度 | App 14 预约 | App 15 分类 | App 16 兑换 |
|---|---|---|---|
| Tab | 待确认/已完成/已取消 | 已分类/待处理/错误 | 待发货/已发货/已完成 |
| 特色模块 | 无 | 占比进度条 | 消耗趋势图 |
| 记录字段 | 场地+时间 | 分类+数量 | 数量+积分+地址 |
| 状态1操作 | 取消/详情 | ✓正确 | 取消/改址 |
| 状态2操作 | 再次预约 | 立即分类 | 查看物流 |
| 状态3操作 | 无 | 重新分类(红) | 再次兑换 |
"记录列表模板"第 3 次复用(App 14/15/16),每次的差异化:App 15 加"占比"、App 16 加"趋势 + 物流状态"——"模板稳定 + 能力递进"是本系列记录页的进化路径。
九、记录页的"财务视图"设计
App 16 兑换页是本系列第一个"财务视图"记录页——每条记录都带"消耗积分":
"消耗 -800 积分"的设计细节:
Row({ space: 6 }) { Text('消耗').fontSize(11).fontColor(C.textDim) Text('-' + r.points + ' 积分').fontSize(12).fontColor(C.primary).fontWeight(FontWeight.Medium) Text('· ' + r.date + ' ' + r.time).fontSize(12).fontColor(C.textDim) }- 负号前缀(
-800)——"支出"的通用符号(收入 +、支出 −),用户一眼识别"这是花掉的" - 主色加粗——"金额最重"原则(财务记录里数字是核心信息)
- 灰字时间——次要信息弱化
"收入视图 vs 支出视图":我的页/首页看"+385 积分"(收入,绿色主色),兑换页看"-800 积分"(支出,玫红主色)——收支双视图,同一种积分两种语义。
真实项目的财务记录进阶:
- 收支汇总:页头显示"本月支出 -3,700 / 收入 +385 / 结余"三行
- 筛选维度:按商品/按时间/按金额排序
- 图表联动:点击趋势图某天 → 下方列表过滤当天记录
"财务视图"模板可复用到:记账 App、钱包 App、积分系统、消费记录——任何"钱/积分流向"的记录场景。
十、记录列表模板的系列进化
回顾系列 3 个"记录列表"页(App 14/15/16),可以清晰看到**"记录模板"的能力递进**:
| 能力 | App 14 | App 15 | App 16 |
|---|---|---|---|
| 真筛选(filtered) | ✓ | ✓ | ✓ |
| 时间线连接 | ✓ | ✓ | ✓ |
| 状态差异化操作 | ✓ | ✓ | ✓ |
| 状态色编码 | 单维 | 双维(分类+状态) | 单维 |
| 数据图表 | 无 | 占比 | 趋势 |
| 业务字段 | 场地/时长 | 分类/数量 | 数量/积分/地址 |
| 特殊操作 | 再次预约 | 红色纠错 | 取消/改址/物流/复购 |
"基础能力(筛选/时间线/状态操作)三连复用 + 业务能力(占比/趋势/财务字段)逐步增加"——模板稳定,能力递进,这是系列化 demo 记录页的进化路径。读者如果要把这套模板用到自己的项目,基础能力直接抄,业务字段按需加。
十一、小结
App 16 兑换记录页解析完毕。物流三态操作(取消/改址 → 物流 → 复购)+ 积分消耗趋势 + 财务负号显示是三大亮点。"总兑换 12 = 首页已兑换 12"跨页一致。