前言

交通出行类页面有一个很鲜明的特点:同一页里经常既有“账户信息”,又有“流水记录”,还会顺带夹带一个高频操作入口,比如充值、开票或者补登行程。页面如果没有把这三类信息组织好,用户会觉得内容很多,但抓不到重点。

这个 HarmonyOS 示例值得看的地方,就在于它没有把乘车记录页写成单纯的列表页,而是做成了一个典型的“账户首页”。上半部分先用余额卡片建立账户感知,中间再用 tab 把明细和统计拆成两种阅读模式,最后加上充值弹窗,形成一个完整的使用闭环。

这页最关键的设计,不是列表,而是信息优先级

A hand-drawn doodle illustration on pure white pap

做这种页面时,最容易犯的错误是把所有内容都平铺出来:上来就是一串乘车记录,然后在某个角落塞个充值按钮。这样虽然功能都在,但用户第一眼很难知道“我卡里还有多少钱”“这个月花了多少”“如果没钱了要去哪里充值”。

这份源码的处理顺序很明确:

  • 先展示余额,让用户立刻知道账户当前状态。
  • 再展示本月花费、乘次和总里程,补齐摘要信息。
  • 然后通过 tab 分成“出行记录”和“消费统计”两个阅读入口。
  • 当用户需要行动时,再用充值弹窗接住主操作。

这说明作者不是把页面当成一张静态长表,而是在按真实使用顺序安排信息层次。这种意识在做工具型应用时非常重要。

状态设计里有两条线:一条是账户线,一条是视图线

这页的状态比一般列表页稍多,但并不乱,因为它其实可以分成两组:

@State balance: number = 45.8
@State activeTab: number = 0
@State trips: TripRecord[] = [ ... ]
@State monthStats: MonthStat[] = [ ... ]
@State showRecharge: boolean = false
@State rechargeAmount: number = 50

第一组是账户和数据本身:balancetripsmonthStats。这些状态决定页面展示什么内容。

第二组是视图和交互上下文:activeTabshowRechargerechargeAmount。这些状态决定用户现在正在看哪一部分、正在执行什么动作。

这样拆开看,你会更容易理解为什么这页虽然既有列表、又有统计、又有弹窗,但依然没有失控。因为它没有把所有变量揉成一团,而是天然分成了“业务数据”和“界面状态”两层。

A hand-drawn doodle illustration on pure white pap

余额卡片为什么放在最上面,而且要顺手带上摘要统计

顶部余额卡片不是单纯为了好看,它承担的是页面的主入口职责。用户点进这类页面,第一优先级通常不是回顾所有历史记录,而是确认两件事:

  • 账户里还有多少钱。
  • 最近用得多不多,需不需要充值。

源码里在一张卡片里把余额、本月花费、本月乘次和总里程放在一起,本质上是在用一屏内容回答这两个问题。这样用户连 tab 都不用切,就能先形成整体判断。

这种写法有个工程上的好处:摘要信息都直接来源于已有状态,不需要再单独维护一套显示数据。比如本月花费由 getTotalThisMonth() 计算,本月乘次直接取 trips.length,总里程则通过 reduce 聚合。也就是说,顶部卡片虽然是 UI 区域,但它依赖的仍然是统一数据源。

getTotalThisMonth() 体现的是“派生数据不要单独存”

源码里最简单但也最值得保留的函数之一,就是这个:

getTotalThisMonth(): number {
  return this.trips.reduce((s: number, t: TripRecord) => s + t.fare, 0)
}

它返回的是“本月总花费”,但没有把这个数字额外存在一个状态字段里。原因很简单,这个值完全可以从 trips 推出来。

这背后的思路很适合教程重点讲清楚:

  • 原始数据应该尽量少,但要完整。
  • 统计结果尽量通过计算得到,不要重复存储。
  • 一旦原始数据变了,统计展示会自动跟着变,不用担心同步问题。

如果你以后要继续扩展这页,比如加上“平均单次花费”“本月最长通勤距离”“优惠节省金额”,都应该优先按同样思路走,先看能不能从已有记录派生,而不是直接再加一堆 @State

A hand-drawn doodle illustration on pure white pap

activeTab 的意义,不只是切换样式,而是切换阅读模式

这页的 tab 只有两个:出行记录消费统计。数量不多,但很典型,因为它切的不是两个无关模块,而是同一份业务数据的两种阅读方式。

  • 出行记录 模式下,用户按单次行程查看站点、线路、金额、时长和里程。
  • 消费统计 模式下,用户把注意力从单条记录切到按月聚合的消费概览。

这和普通“切频道”不太一样。它更像是在问用户:你现在想看明细,还是想看总结?

所以 activeTab 的价值也不只是控制高亮颜色,而是在控制整页的信息密度和阅读角度。很多工具类页面都适合用这种结构,因为同一份数据通常既要支持“追溯明细”,又要支持“快速汇总”。

getLineColor() 虽然小,但说明线路信息已经被做成了可识别语义

地铁记录页里,如果线路只是一段文字,页面很容易看起来像一串流水账。源码单独写了 getLineColor()

getLineColor(line: string): string {
  const colors: Record<string, string> = {
    '1号线': '#E94A35', '2号线': '#007AFF', '3号线': '#FF9500',
    '4号线': '#34C759', '7号线': '#5856D6', '10号线': '#FF2D92',
  }
  return colors[line] ?? '#888888'
}

这背后做的事情其实是“把线路名称转成可扫读标识”。

对用户来说,线路颜色比文字更快被识别。对代码来说,这又是一层典型的展示映射:原始字段是 line,页面最终需要的是一个视觉系统里的颜色值。以后如果接入更多线路,或者做不同城市的线路主题,这个函数也天然是扩展点。

充值弹窗是这页里最完整的一段状态流转

如果说列表和统计更多是在展示数据,那么充值弹窗就是在真正修改状态。它的交互过程其实很完整:

  1. 点击顶部“充值”,把 showRecharge 设为 true,弹窗出现。
  2. 在预设金额里点击某个值,更新 rechargeAmount
  3. 点击确认按钮,把金额加到 balance 上,同时关闭弹窗。
  4. 余额卡片顶部数字立即刷新。

这里最值得讲的是:这段交互没有引入额外复杂机制,但已经把“弹窗开关”“当前选择”“提交结果”三件事串好了。也就是说,一个小功能已经具备了完整的状态流。

这类例子很适合给初学者建立感觉:不是只有表单页才叫状态管理,像充值弹窗这种局部交互,同样是在做严肃的状态流转。

为什么这页适合做“账户首页”类页面的模板

把整份代码看完,你会发现它其实已经具备很多账户首页的通用元素:

  • 顶部摘要卡片。
  • 中间双视图切换。
  • 下层明细列表或统计面板。
  • 一条高频主操作入口。
  • 局部弹窗交互。

这套结构几乎可以直接迁移到很多别的业务里,比如校园一卡通、停车月卡、健身卡消费记录、企业报销余额页。改的通常只是数据模型和视觉样式,页面骨架和状态组织方式都很相似。

如果继续做深,这几个方向最值得扩展

这个案例已经能作为一个完整示例,但如果往正式项目推进,我建议优先补下面几件事:

  • trips 改成按日期分组,提升长列表可读性。
  • monthStats 增加更多指标,比如优惠金额、平均单次花费。
  • 让充值金额支持自定义输入,而不只是预设选项。
  • 把余额、记录、统计都接到统一数据层,避免示例数据只存在页面文件里。
  • 增加空记录态、加载态和充值失败提示,这些在真实应用里都少不了。

尤其是最后两点,一旦接接口,这页就会从“示例”变成真正的业务页面,届时状态管理和边界处理的重要性会比现在更明显。

完整代码

下面保留案例完整代码,方便你直接对照学习、复制和重构。

// 地铁乘车记录页: 地铁乘车记录 - 行程卡片、消费统计、充值入口

interface TripRecord {
  id: number
  date: string
  enterStation: string
  exitStation: string
  line: string
  fare: number
  duration: number  // 分钟
  distance: number  // km
  discountRate: number
}

interface MonthStat {
  month: string
  trips: number
  totalFare: number
  totalDistance: number
}

@Entry
@Component
struct MetroTripRecords {
  @State balance: number = 45.8
  @State activeTab: number = 0
  @State trips: TripRecord[] = [
    { id: 1, date: '06-06 09:15', enterStation: '天府广场', exitStation: '高新区', line: '1号线', fare: 4.0, duration: 22, distance: 15.2, discountRate: 0.9 },
    { id: 2, date: '06-06 08:30', enterStation: '双流机场', exitStation: '天府广场', line: '10号线', fare: 7.0, duration: 38, distance: 28.5, discountRate: 1.0 },
    { id: 3, date: '06-05 18:45', enterStation: '高新区', exitStation: '春熙路', line: '1号线', fare: 3.0, duration: 18, distance: 10.8, discountRate: 0.9 },
    { id: 4, date: '06-05 08:20', enterStation: '犀浦', exitStation: '天府广场', line: '2号线', fare: 5.0, duration: 30, distance: 22.0, discountRate: 0.9 },
    { id: 5, date: '06-04 20:00', enterStation: '天府广场', exitStation: '龙泉驿', line: '7号线', fare: 6.0, duration: 35, distance: 25.3, discountRate: 0.95 },
    { id: 6, date: '06-04 09:10', enterStation: '科华北路', exitStation: '高新区', line: '1号线', fare: 2.0, duration: 12, distance: 6.5, discountRate: 0.9 },
    { id: 7, date: '06-03 17:50', enterStation: '太平园', exitStation: '春熙路', line: '4号线', fare: 3.0, duration: 15, distance: 8.2, discountRate: 0.9 },
    { id: 8, date: '06-03 08:00', enterStation: '牛王庙', exitStation: '盐市口', line: '3号线', fare: 2.0, duration: 10, distance: 5.5, discountRate: 0.9 },
  ]
  @State monthStats: MonthStat[] = [
    { month: '6月', trips: 8, totalFare: 32.0, totalDistance: 122.0 },
    { month: '5月', trips: 42, totalFare: 168.5, totalDistance: 635.0 },
    { month: '4月', trips: 38, totalFare: 152.0, totalDistance: 570.0 },
    { month: '3月', trips: 45, totalFare: 180.0, totalDistance: 675.0 },
  ]
  @State showRecharge: boolean = false
  @State rechargeAmount: number = 50

  getTotalThisMonth(): number {
    return this.trips.reduce((s: number, t: TripRecord) => s + t.fare, 0)
  }

  getLineColor(line: string): string {
    const colors: Record<string, string> = {
      '1号线': '#E94A35', '2号线': '#007AFF', '3号线': '#FF9500',
      '4号线': '#34C759', '7号线': '#5856D6', '10号线': '#FF2D92',
    }
    return colors[line] ?? '#888888'
  }

  build() {
    Column() {
      // 余额卡片
      Column() {
        Row() {
          Column() {
            Text('公交地铁卡').fontSize(13).fontColor('#FFFFFF80')
            Text(`¥ ${this.balance.toFixed(2)}`).fontSize(32).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          }
          .alignItems(HorizontalAlign.Start)
          Blank()
          Button('充值')
            .fontSize(13).fontColor('#5856D6')
            .backgroundColor('#FFFFFF').borderRadius(20).height(36)
            .padding({ left: 16, right: 16 })
            .onClick(() => { this.showRecharge = true })
        }
        .width('100%')
        .margin({ bottom: 16 })

        Row({ space: 32 }) {
          Column() {
            Text(`¥${this.getTotalThisMonth().toFixed(1)}`).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('本月花费').fontSize(11).fontColor('#FFFFFF80').margin({ top: 2 })
          }
          Column() {
            Text(String(this.trips.length)).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('本月乘次').fontSize(11).fontColor('#FFFFFF80').margin({ top: 2 })
          }
          Column() {
            Text(`${this.trips.reduce((s: number, t: TripRecord) => s + t.distance, 0).toFixed(0)}km`)
              .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('总里程').fontSize(11).fontColor('#FFFFFF80').margin({ top: 2 })
          }
        }
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 24, bottom: 20 })
      .linearGradient({ angle: 135, colors: [['#5856D6', 0], ['#007AFF', 1]] })

      // Tab
      Row() {
        ForEach(['出行记录', '消费统计'], (tab: string, idx: number) => {
          Text(tab)
            .fontSize(14)
            .fontColor(this.activeTab === idx ? '#007AFF' : '#888888')
            .fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .border({ width: { bottom: this.activeTab === idx ? 2 : 0 }, color: { bottom: '#007AFF' } })
            .onClick(() => { this.activeTab = idx })
        })
      }
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 8 })

      if (this.activeTab === 0) {
        // 出行记录
        List({ space: 8 }) {
          ForEach(this.trips, (trip: TripRecord) => {
            ListItem() {
              Row() {
                // 线路标识
                Column() {
                  Text(trip.line)
                    .fontSize(11).fontColor('#FFFFFF')
                    .backgroundColor(this.getLineColor(trip.line))
                    .borderRadius(6).padding({ left: 6, right: 6, top: 3, bottom: 3 })
                  Text(trip.date.split(' ')[1]).fontSize(11).fontColor('#AAAAAA').margin({ top: 4 })
                }
                .width(60)

                // 站点连线
                Column() {
                  Text(trip.enterStation).fontSize(13).fontColor('#333333')
                  Row() {
                    Column()
                      .width(1.5).height(20)
                      .backgroundColor('#DDDDDD')
                      .margin({ left: 3 })
                  }
                  Text(trip.exitStation).fontSize(13).fontColor('#333333')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 12 })

                Column() {
                  Text(`¥${trip.fare.toFixed(1)}`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF3B30')
                  Text(`${trip.duration}分钟`).fontSize(11).fontColor('#AAAAAA').margin({ top: 2 })
                  Text(`${trip.distance}km`).fontSize(11).fontColor('#AAAAAA').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%')
              .padding(14)
              .backgroundColor('#FFFFFF')
              .borderRadius(12)
            }
          })
        }
        .layoutWeight(1)
        .padding({ left: 16, right: 16 })
        .scrollBar(BarState.Off)
      } else {
        // 消费统计
        Scroll() {
          Column({ space: 12 }) {
            ForEach(this.monthStats, (stat: MonthStat) => {
              Row() {
                Column() {
                  Text(stat.month).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
                  Text(`${stat.trips} 次出行`).fontSize(12).fontColor('#888888').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start)
                .width(70)

                Progress({
                  value: stat.totalFare,
                  total: 200,
                  type: ProgressType.Linear
                })
                .layoutWeight(1)
                .height(8)
                .color('#007AFF')
                .backgroundColor('#E5E5E5')
                .borderRadius(4)
                .margin({ left: 10, right: 10 })

                Text(`¥${stat.totalFare.toFixed(0)}`).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FF3B30').width(55).textAlign(TextAlign.End)
              }
              .width('100%')
              .padding(14)
              .backgroundColor('#FFFFFF')
              .borderRadius(12)
            })
          }
          .padding({ left: 16, right: 16, bottom: 20 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
      }

      // 充值弹窗
      if (this.showRecharge) {
        Stack() {
          Column().width('100%').height('100%').backgroundColor('#00000050')
            .onClick(() => { this.showRecharge = false })
          Column({ space: 16 }) {
            Text('充值').fontSize(17).fontWeight(FontWeight.Bold)
            Text(`当前余额:¥${this.balance.toFixed(2)}`).fontSize(13).fontColor('#888888')
            Row({ space: 10 }) {
              ForEach([20, 50, 100, 200], (amt: number) => {
                Text(`¥${amt}`)
                  .fontSize(15).fontWeight(FontWeight.Bold)
                  .fontColor(this.rechargeAmount === amt ? '#FFFFFF' : '#333333')
                  .backgroundColor(this.rechargeAmount === amt ? '#007AFF' : '#F5F5F5')
                  .borderRadius(10).padding({ left: 14, right: 14, top: 10, bottom: 10 })
                  .onClick(() => { this.rechargeAmount = amt })
              })
            }
            Row({ space: 12 }) {
              Button('取消').layoutWeight(1).height(44)
                .fontColor('#007AFF').backgroundColor('#F5F5F5').borderRadius(12)
                .onClick(() => { this.showRecharge = false })
              Button(`充值 ¥${this.rechargeAmount}`).layoutWeight(1).height(44)
                .fontColor('#FFFFFF').backgroundColor('#007AFF').borderRadius(12)
                .onClick(() => {
                  this.balance += this.rechargeAmount
                  this.showRecharge = false
                })
            }
          }
          .width('85%').padding(20).backgroundColor('#FFFFFF').borderRadius(16)
        }
        .width('100%').height('100%').position({ x: 0, y: 0 })
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F8F8F8')
  }
}

最后总结

这页的价值不在于组件有多复杂,而在于它把账户余额、出行明细、月度统计和充值操作放进了同一条清晰的状态链里。把这种“摘要先行、明细分流、动作就近”的组织方式学会之后,你再写任何账户型工具页,都会更容易做出有层次的结果。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐