鸿蒙原生应用 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 回答"积分怎么花掉的"。

校园积分商城兑换页首屏 · Header+4个胶囊Tab+统计概览+积分消耗趋势条形图

项目源码开源: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) 跟随内容(前几篇已反复提醒的"断线"问题)。

校园积分商城兑换页底部 · 最后一条记录+安全区留白+底部Tab栏

八、与 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)
}
  1. 负号前缀-800)——"支出"的通用符号(收入 +、支出 −),用户一眼识别"这是花掉的"
  2. 主色加粗——"金额最重"原则(财务记录里数字是核心信息)
  3. 灰字时间——次要信息弱化

"收入视图 vs 支出视图":我的页/首页看"+385 积分"(收入,绿色主色),兑换页看"-800 积分"(支出,玫红主色)——收支双视图,同一种积分两种语义

真实项目的财务记录进阶

  • 收支汇总:页头显示"本月支出 -3,700 / 收入 +385 / 结余"三行
  • 筛选维度:按商品/按时间/按金额排序
  • 图表联动:点击趋势图某天 → 下方列表过滤当天记录

"财务视图"模板可复用到:记账 App、钱包 App、积分系统、消费记录——任何"钱/积分流向"的记录场景。

校园积分商城兑换页中段 · 兑换记录时间线前2条(待发货取消/改址+已发货物流)

十、记录列表模板的系列进化

回顾系列 3 个"记录列表"页(App 14/15/16),可以清晰看到**"记录模板"的能力递进**:

能力App 14App 15App 16
真筛选(filtered)
时间线连接
状态差异化操作
状态色编码单维双维(分类+状态)单维
数据图表占比趋势
业务字段场地/时长分类/数量数量/积分/地址
特殊操作再次预约红色纠错取消/改址/物流/复购

"基础能力(筛选/时间线/状态操作)三连复用 + 业务能力(占比/趋势/财务字段)逐步增加"——模板稳定,能力递进,这是系列化 demo 记录页的进化路径。读者如果要把这套模板用到自己的项目,基础能力直接抄,业务字段按需加

十一、小结

App 16 兑换记录页解析完毕。物流三态操作(取消/改址 → 物流 → 复购)+ 积分消耗趋势 + 财务负号显示是三大亮点。"总兑换 12 = 首页已兑换 12"跨页一致

Logo

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

更多推荐