HarmonyOS7地铁乘车记录页实战:出行记录统计与余额管理
文章目录
前言
交通出行类页面有一个很鲜明的特点:同一页里经常既有“账户信息”,又有“流水记录”,还会顺带夹带一个高频操作入口,比如充值、开票或者补登行程。页面如果没有把这三类信息组织好,用户会觉得内容很多,但抓不到重点。
这个 HarmonyOS 示例值得看的地方,就在于它没有把乘车记录页写成单纯的列表页,而是做成了一个典型的“账户首页”。上半部分先用余额卡片建立账户感知,中间再用 tab 把明细和统计拆成两种阅读模式,最后加上充值弹窗,形成一个完整的使用闭环。
这页最关键的设计,不是列表,而是信息优先级

做这种页面时,最容易犯的错误是把所有内容都平铺出来:上来就是一串乘车记录,然后在某个角落塞个充值按钮。这样虽然功能都在,但用户第一眼很难知道“我卡里还有多少钱”“这个月花了多少”“如果没钱了要去哪里充值”。
这份源码的处理顺序很明确:
- 先展示余额,让用户立刻知道账户当前状态。
- 再展示本月花费、乘次和总里程,补齐摘要信息。
- 然后通过 tab 分成“出行记录”和“消费统计”两个阅读入口。
- 当用户需要行动时,再用充值弹窗接住主操作。
这说明作者不是把页面当成一张静态长表,而是在按真实使用顺序安排信息层次。这种意识在做工具型应用时非常重要。
状态设计里有两条线:一条是账户线,一条是视图线
这页的状态比一般列表页稍多,但并不乱,因为它其实可以分成两组:
@State balance: number = 45.8
@State activeTab: number = 0
@State trips: TripRecord[] = [ ... ]
@State monthStats: MonthStat[] = [ ... ]
@State showRecharge: boolean = false
@State rechargeAmount: number = 50
第一组是账户和数据本身:balance、trips、monthStats。这些状态决定页面展示什么内容。
第二组是视图和交互上下文:activeTab、showRecharge、rechargeAmount。这些状态决定用户现在正在看哪一部分、正在执行什么动作。
这样拆开看,你会更容易理解为什么这页虽然既有列表、又有统计、又有弹窗,但依然没有失控。因为它没有把所有变量揉成一团,而是天然分成了“业务数据”和“界面状态”两层。

余额卡片为什么放在最上面,而且要顺手带上摘要统计
顶部余额卡片不是单纯为了好看,它承担的是页面的主入口职责。用户点进这类页面,第一优先级通常不是回顾所有历史记录,而是确认两件事:
- 账户里还有多少钱。
- 最近用得多不多,需不需要充值。
源码里在一张卡片里把余额、本月花费、本月乘次和总里程放在一起,本质上是在用一屏内容回答这两个问题。这样用户连 tab 都不用切,就能先形成整体判断。
这种写法有个工程上的好处:摘要信息都直接来源于已有状态,不需要再单独维护一套显示数据。比如本月花费由 getTotalThisMonth() 计算,本月乘次直接取 trips.length,总里程则通过 reduce 聚合。也就是说,顶部卡片虽然是 UI 区域,但它依赖的仍然是统一数据源。
getTotalThisMonth() 体现的是“派生数据不要单独存”
源码里最简单但也最值得保留的函数之一,就是这个:
getTotalThisMonth(): number {
return this.trips.reduce((s: number, t: TripRecord) => s + t.fare, 0)
}
它返回的是“本月总花费”,但没有把这个数字额外存在一个状态字段里。原因很简单,这个值完全可以从 trips 推出来。
这背后的思路很适合教程重点讲清楚:
- 原始数据应该尽量少,但要完整。
- 统计结果尽量通过计算得到,不要重复存储。
- 一旦原始数据变了,统计展示会自动跟着变,不用担心同步问题。
如果你以后要继续扩展这页,比如加上“平均单次花费”“本月最长通勤距离”“优惠节省金额”,都应该优先按同样思路走,先看能不能从已有记录派生,而不是直接再加一堆 @State。

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,页面最终需要的是一个视觉系统里的颜色值。以后如果接入更多线路,或者做不同城市的线路主题,这个函数也天然是扩展点。
充值弹窗是这页里最完整的一段状态流转
如果说列表和统计更多是在展示数据,那么充值弹窗就是在真正修改状态。它的交互过程其实很完整:
- 点击顶部“充值”,把
showRecharge设为true,弹窗出现。 - 在预设金额里点击某个值,更新
rechargeAmount。 - 点击确认按钮,把金额加到
balance上,同时关闭弹窗。 - 余额卡片顶部数字立即刷新。
这里最值得讲的是:这段交互没有引入额外复杂机制,但已经把“弹窗开关”“当前选择”“提交结果”三件事串好了。也就是说,一个小功能已经具备了完整的状态流。
这类例子很适合给初学者建立感觉:不是只有表单页才叫状态管理,像充值弹窗这种局部交互,同样是在做严肃的状态流转。
为什么这页适合做“账户首页”类页面的模板
把整份代码看完,你会发现它其实已经具备很多账户首页的通用元素:
- 顶部摘要卡片。
- 中间双视图切换。
- 下层明细列表或统计面板。
- 一条高频主操作入口。
- 局部弹窗交互。
这套结构几乎可以直接迁移到很多别的业务里,比如校园一卡通、停车月卡、健身卡消费记录、企业报销余额页。改的通常只是数据模型和视觉样式,页面骨架和状态组织方式都很相似。
如果继续做深,这几个方向最值得扩展
这个案例已经能作为一个完整示例,但如果往正式项目推进,我建议优先补下面几件事:
- 把
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')
}
}
最后总结
这页的价值不在于组件有多复杂,而在于它把账户余额、出行明细、月度统计和充值操作放进了同一条清晰的状态链里。把这种“摘要先行、明细分流、动作就近”的组织方式学会之后,你再写任何账户型工具页,都会更容易做出有层次的结果。
更多推荐

所有评论(0)