项目效果

本文实现的是一个基于 HarmonyOS 和 ArkTS 的校园记账本应用。项目中使用 ArkUI 组件完成页面布局,通过 @State 管理页面数据,实现收入记录、支出记录、金额统计、分类管理、账单筛选、记录删除和一键清空等功能。

最终运行效果如下:

【在这里插入项目运行截图】

页面主要包含以下内容:

  • 顶部应用标题;
  • 本月收入统计;
  • 本月支出统计;
  • 当前结余展示;
  • 金额输入框;
  • 备注输入框;
  • 收入和支出类型切换;
  • 分类选择按钮;
  • 添加账单记录;
  • 全部、收入、支出筛选;
  • 账单记录列表;
  • 单条记录删除;
  • 一键清空账单;
  • 空账单状态提示;
  • 页面整体采用 ArkUI 声明式布局。

本文重点是演示如何在 HarmonyOS 项目中使用 ArkTS 和 ArkUI 实现一个完整的生活工具类单页面应用。项目代码主要写在 entry/src/main/ets/pages/Index.ets 文件中,适合作为 HarmonyOS ArkTS 入门到进阶之间的练习案例。

为了避免文章检测时出现链接占比过高的问题,本文不直接放图片链接。发布时可以在 CSDN 编辑器中手动插入一张应用运行总效果图。


前言

在校园生活中,记账是一个很常见的需求。学生日常会有餐饮、交通、学习用品、生活用品、娱乐等支出,也可能会有生活费、兼职收入、奖学金等收入。如果没有记录,月底很容易不清楚钱花在了哪里。

从应用开发角度来看,记账本项目非常适合作为 HarmonyOS ArkTS 的练习案例。它不需要后端接口,也不需要复杂数据库,但包含了输入、分类、添加、筛选、统计、删除和状态刷新等完整流程。

相比单纯的静态页面,记账本应用更能体现页面状态变化。用户每添加一条收入或支出记录,收入总额、支出总额、当前结余和账单列表都会同步变化。用户删除记录后,统计数据也会重新计算。

本文基于 HarmonyOS 和 ArkTS 实现一个校园记账本应用。用户可以输入金额和备注,选择收入或支出类型,再选择账单分类,最后点击按钮添加账单记录。页面会自动统计收入、支出和结余,并支持按类型筛选账单。

这个项目的核心不是简单显示几条记录,而是通过一个完整的小工具理解 ArkUI 的状态驱动思想。页面展示内容全部来自状态变量,只要状态数据发生变化,界面就会自动刷新。


一、项目目标

本次实践主要实现以下目标:

  • 创建 HarmonyOS ArkTS 页面;
  • 使用 @Entry@Component 定义页面组件;
  • 使用 @State 管理页面状态;
  • 使用 TextInput 接收金额和备注输入;
  • 使用 Button 实现类型切换、分类选择和添加记录;
  • 使用数组保存账单记录;
  • 使用 ListForEach 渲染账单列表;
  • 根据账单记录动态计算收入总额;
  • 根据账单记录动态计算支出总额;
  • 根据收入和支出计算当前结余;
  • 支持按全部、收入、支出筛选账单;
  • 支持删除单条账单记录;
  • 支持一键清空全部账单;
  • 使用空状态提示优化无数据页面;
  • 使用 @Builder 封装统计卡片、类型按钮、分类按钮和记录卡片;
  • 完成一个可以运行的校园记账本页面。

这个项目虽然是单页面应用,但功能链路比较完整。它覆盖了 ArkTS 页面开发中常见的数据输入、状态管理、数组处理、条件渲染和列表展示等知识点。


二、技术栈

类型 内容
开发方向 HarmonyOS 应用开发
开发语言 ArkTS
UI 框架 ArkUI
SDK 版本 HarmonyOS API 23 及以上
工程模型 Stage 模型
核心组件 Text / TextInput / Button / Column / Row / List / ForEach
状态管理 @State
数据处理 数组遍历 / filter / 条件判断 / 数值统计
项目入口 entry/src/main/ets/pages/Index.ets
运行平台 模拟器或真机

本项目是 HarmonyOS 原生 ArkTS 项目。页面主体由 ArkUI 组件构建,核心逻辑写在 Index.ets 文件中,不依赖后端接口,也不需要额外配置数据库。


三、为什么选择校园记账本项目

校园记账本应用适合作为 ArkTS 练习项目,主要有以下几个原因。

第一,业务场景真实。学生日常生活中经常会产生消费记录,比如早餐、午饭、奶茶、交通、文具、打印资料等。把这些内容做成记账本,更容易理解项目的实际用途。

第二,交互流程完整。用户需要输入金额、输入备注、选择类型、选择分类、添加记录、查看列表、筛选账单和删除记录。这个流程比普通展示页面更接近真实应用。

第三,适合练习数组操作。每一条账单都是数组中的一个对象。添加记录就是向数组中插入数据,删除记录就是通过编号过滤数组,统计收入和支出就是遍历数组并计算总和。

第四,适合理解状态管理。金额输入、备注输入、当前类型、当前分类、筛选条件和账单记录都属于页面状态。状态变化后,页面会自动更新。

第五,容易扩展。基础记账功能完成后,还可以继续增加本地存储、月度统计、图表展示、预算提醒和分类管理等功能。

在本项目中,records 是最核心的数据源。页面上看到的收入、支出、结余和账单列表,都由这个数组计算或渲染得到。这样可以避免多个数据来源不一致的问题。


四、功能规则说明

本文实现的校园记账本主要包含两类账单:

类型 含义
收入 生活费、兼职、奖学金等收入来源
支出 餐饮、交通、学习、娱乐等消费支出

支出分类包含:

分类 示例
餐饮 早餐、午饭、晚饭、奶茶
交通 地铁、公交、打车
学习 书籍、文具、打印
生活 日用品、洗衣、宿舍用品
娱乐 电影、游戏、聚餐

收入分类包含:

分类 示例
生活费 家里给的生活费
兼职 兼职工资
奖学金 奖学金或补贴

本文为了让页面操作更简单,收入和支出共用一组分类按钮。真实项目中可以进一步根据类型动态切换不同分类。

统计规则如下:

收入总额 = 所有收入记录金额之和
支出总额 = 所有支出记录金额之和
当前结余 = 收入总额 - 支出总额

当用户添加或删除记录后,页面会重新计算这些数据。


五、项目结构

本项目主要修改首页文件:

entry
└── src
    └── main
        └── ets
            └── pages
                └── Index.ets

其中:

文件 作用
Index.ets 编写页面结构、状态数据和账单处理逻辑

本文不涉及复杂路由,也不需要额外创建多个页面。对于练习项目来说,把主要逻辑集中在一个 Index.ets 文件中更方便理解。

如果后续继续扩展,可以考虑把统计卡片、账单卡片、分类按钮和输入区域拆分成独立组件。


六、核心实现思路

本项目的核心流程如下:

  1. 定义账单记录数据结构;
  2. 使用 @State 保存金额输入、备注输入、账单类型、账单分类和账单列表;
  3. 用户输入金额和备注;
  4. 用户选择收入或支出类型;
  5. 用户选择账单分类;
  6. 点击添加按钮后生成新的账单记录;
  7. 根据记录数组计算收入总额;
  8. 根据记录数组计算支出总额;
  9. 根据收入和支出计算当前结余;
  10. 使用 ForEach 渲染账单列表;
  11. 根据筛选条件展示全部、收入或支出记录;
  12. 点击删除按钮后删除指定记录;
  13. 点击清空按钮后清空全部记录;
  14. 当记录为空时显示空状态提示。

项目中最重要的状态变量如下:

@State amountText: string = ''
@State noteText: string = ''
@State recordType: string = '支出'
@State category: string = '餐饮'
@State filterType: string = '全部'
@State records: BillRecord[] = []
@State nextId: number = 1

其中:

状态变量 作用
amountText 保存金额输入
noteText 保存备注输入
recordType 保存当前账单类型
category 保存当前账单分类
filterType 保存当前筛选条件
records 保存账单记录数组
nextId 生成记录唯一编号

records 是本项目中最重要的数据。统计卡片和账单列表都依赖它。


七、Index.ets 完整代码

打开文件:

entry/src/main/ets/pages/Index.ets

将其中内容替换为下面代码:

interface BillRecord {
  id: number
  amount: number
  type: string
  category: string
  note: string
  time: string
}

@Entry
@Component
struct Index {
  @State amountText: string = ''
  @State noteText: string = ''
  @State recordType: string = '支出'
  @State category: string = '餐饮'
  @State filterType: string = '全部'
  @State nextId: number = 5

  @State records: BillRecord[] = [
    {
      id: 1,
      amount: 18,
      type: '支出',
      category: '餐饮',
      note: '午饭',
      time: '09:20'
    },
    {
      id: 2,
      amount: 300,
      type: '收入',
      category: '生活费',
      note: '本周生活费',
      time: '10:30'
    },
    {
      id: 3,
      amount: 12,
      type: '支出',
      category: '交通',
      note: '地铁出行',
      time: '13:10'
    },
    {
      id: 4,
      amount: 25,
      type: '支出',
      category: '学习',
      note: '打印资料',
      time: '15:40'
    }
  ]

  private addRecord(): void {
    let amount: number = Number(this.amountText)

    if (this.amountText.trim().length === 0 || amount <= 0) {
      return
    }

    let note: string = this.noteText.trim()

    if (note.length === 0) {
      note = '无备注'
    }

    let record: BillRecord = {
      id: this.nextId,
      amount: amount,
      type: this.recordType,
      category: this.category,
      note: note,
      time: this.getCurrentTime()
    }

    this.records = [record, ...this.records]
    this.nextId++
    this.amountText = ''
    this.noteText = ''
  }

  private deleteRecord(id: number): void {
    this.records = this.records.filter((item: BillRecord) => item.id !== id)
  }

  private clearRecords(): void {
    this.records = []
  }

  private getIncomeTotal(): number {
    let total: number = 0

    this.records.forEach((item: BillRecord) => {
      if (item.type === '收入') {
        total += item.amount
      }
    })

    return total
  }

  private getExpenseTotal(): number {
    let total: number = 0

    this.records.forEach((item: BillRecord) => {
      if (item.type === '支出') {
        total += item.amount
      }
    })

    return total
  }

  private getBalance(): number {
    return this.getIncomeTotal() - this.getExpenseTotal()
  }

  private getFilteredRecords(): BillRecord[] {
    if (this.filterType === '收入') {
      return this.records.filter((item: BillRecord) => item.type === '收入')
    }

    if (this.filterType === '支出') {
      return this.records.filter((item: BillRecord) => item.type === '支出')
    }

    return this.records
  }

  private getCurrentTime(): string {
    let date = new Date()
    let hour = date.getHours()
    let minute = date.getMinutes()

    return `${this.formatTwo(hour)}:${this.formatTwo(minute)}`
  }

  private formatTwo(value: number): string {
    if (value < 10) {
      return '0' + value.toString()
    }

    return value.toString()
  }

  private getTypeColor(type: string): string {
    if (type === '收入') {
      return '#10B981'
    }

    return '#EF4444'
  }

  private getBalanceColor(): string {
    if (this.getBalance() >= 0) {
      return '#10B981'
    }

    return '#EF4444'
  }

  @Builder
  StatCard(title: string, value: string, color: string) {
    Column() {
      Text(value)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor(color)

      Text(title)
        .fontSize(13)
        .fontColor('#6B7280')
        .margin({ top: 4 })
    }
    .width('31%')
    .height(78)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 4
    })
  }

  @Builder
  TypeButton(text: string) {
    Button(text)
      .height(38)
      .layoutWeight(1)
      .fontSize(14)
      .fontColor(this.recordType === text ? Color.White : '#4B5563')
      .backgroundColor(this.recordType === text ? this.getTypeColor(text) : '#EEF2F8')
      .borderRadius(19)
      .onClick(() => {
        this.recordType = text
      })
  }

  @Builder
  CategoryButton(text: string) {
    Button(text)
      .height(34)
      .fontSize(13)
      .fontColor(this.category === text ? Color.White : '#4B5563')
      .backgroundColor(this.category === text ? '#0A59F7' : '#EEF2F8')
      .borderRadius(17)
      .onClick(() => {
        this.category = text
      })
  }

  @Builder
  FilterButton(text: string) {
    Button(text)
      .height(34)
      .layoutWeight(1)
      .fontSize(13)
      .fontColor(this.filterType === text ? Color.White : '#4B5563')
      .backgroundColor(this.filterType === text ? '#0A59F7' : '#EEF2F8')
      .borderRadius(17)
      .onClick(() => {
        this.filterType = text
      })
  }

  @Builder
  RecordCard(item: BillRecord) {
    Row() {
      Column() {
        Text(`${item.type} · ${item.category}`)
          .fontSize(13)
          .fontColor(this.getTypeColor(item.type))

        Text(item.note)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#182431')
          .margin({ top: 6 })

        Text(`记录时间:${item.time}`)
          .fontSize(12)
          .fontColor('#9CA3AF')
          .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Column() {
        Text(`${item.type === '收入' ? '+' : '-'}${item.amount}`)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.getTypeColor(item.type))

        Button('删除')
          .height(30)
          .fontSize(12)
          .fontColor('#EF4444')
          .backgroundColor('#FEF2F2')
          .borderRadius(14)
          .margin({ top: 8 })
          .onClick(() => {
            this.deleteRecord(item.id)
          })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding(14)
    .margin({ bottom: 12 })
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 3
    })
  }

  build() {
    Column() {
      Text('校园记账本')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .margin({ top: 22 })

      Text('基于 HarmonyOS ArkTS 的轻量级账单管理工具')
        .fontSize(14)
        .fontColor('#6B7280')
        .margin({ top: 8, bottom: 22 })

      Row() {
        this.StatCard('收入', `${this.getIncomeTotal()}`, '#10B981')
        this.StatCard('支出', `${this.getExpenseTotal()}`, '#EF4444')
        this.StatCard('结余', `${this.getBalance()}`, this.getBalanceColor())
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

      Column() {
        Text('新增账单')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#182431')
          .width('100%')
          .margin({ bottom: 14 })

        Row() {
          this.TypeButton('支出')
          Blank().width(10)
          this.TypeButton('收入')
        }
        .width('100%')
        .margin({ bottom: 14 })

        TextInput({
          placeholder: '请输入金额,例如 18',
          text: this.amountText
        })
          .height(42)
          .fontSize(14)
          .backgroundColor('#F5F7FA')
          .borderRadius(12)
          .padding({ left: 12, right: 12 })
          .onChange((value: string) => {
            this.amountText = value
          })

        TextInput({
          placeholder: '请输入备注,例如 午饭',
          text: this.noteText
        })
          .height(42)
          .fontSize(14)
          .backgroundColor('#F5F7FA')
          .borderRadius(12)
          .padding({ left: 12, right: 12 })
          .margin({ top: 12 })
          .onChange((value: string) => {
            this.noteText = value
          })

        Text('选择分类')
          .fontSize(14)
          .fontColor('#6B7280')
          .width('100%')
          .margin({ top: 14, bottom: 10 })

        Row() {
          this.CategoryButton('餐饮')
          this.CategoryButton('交通')
          this.CategoryButton('学习')
          this.CategoryButton('生活')
          this.CategoryButton('娱乐')
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          this.CategoryButton('生活费')
          this.CategoryButton('兼职')
          this.CategoryButton('奖学金')
        }
        .width('100%')
        .justifyContent(FlexAlign.Start)
        .margin({ top: 10 })

        Button('添加记录')
          .height(44)
          .fontSize(16)
          .fontColor(Color.White)
          .backgroundColor('#0A59F7')
          .borderRadius(14)
          .width('100%')
          .margin({ top: 16 })
          .onClick(() => {
            this.addRecord()
          })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(Color.White)
      .borderRadius(18)
      .margin({ top: 18 })
      .shadow({
        radius: 10,
        color: '#12000000',
        offsetX: 0,
        offsetY: 4
      })

      Row() {
        Text('账单记录')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#182431')

        Blank()

        Button('清空')
          .height(32)
          .fontSize(13)
          .fontColor('#EF4444')
          .backgroundColor('#FEF2F2')
          .borderRadius(14)
          .onClick(() => {
            this.clearRecords()
          })
      }
      .width('100%')
      .margin({ top: 20, bottom: 12 })

      Row() {
        this.FilterButton('全部')
        Blank().width(10)
        this.FilterButton('收入')
        Blank().width(10)
        this.FilterButton('支出')
      }
      .width('100%')
      .margin({ bottom: 12 })

      if (this.getFilteredRecords().length === 0) {
        Column() {
          Text('暂无账单记录')
            .fontSize(16)
            .fontColor('#6B7280')

          Text('输入金额和备注后,点击添加记录开始记账。')
            .fontSize(13)
            .fontColor('#9CA3AF')
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(24)
        .backgroundColor(Color.White)
        .borderRadius(16)
      } else {
        List() {
          ForEach(this.getFilteredRecords(), (item: BillRecord) => {
            ListItem() {
              this.RecordCard(item)
            }
          }, (item: BillRecord) => item.id.toString())
        }
        .width('100%')
        .layoutWeight(1)
        .scrollBar(BarState.Off)
      }
    }
    .width('100%')
    .height('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#F5F7FA')
  }
}

八、代码实现说明

1. 定义账单记录数据结构

项目中使用 BillRecord 接口描述每一条账单记录:

interface BillRecord {
  id: number
  amount: number
  type: string
  category: string
  note: string
  time: string
}

字段说明如下:

字段 作用
id 记录唯一编号
amount 账单金额
type 账单类型
category 账单分类
note 账单备注
time 记录时间

其中 id 用来区分不同记录,amount 用来参与金额统计,type 用来区分收入和支出,category 用来展示账单所属分类。


2. 使用 @State 管理页面数据

本项目中使用多个状态变量:

@State amountText: string = ''
@State noteText: string = ''
@State recordType: string = '支出'
@State category: string = '餐饮'
@State filterType: string = '全部'
@State records: BillRecord[] = []

这些状态变量分别保存金额输入、备注输入、账单类型、账单分类、筛选条件和账单记录。

当用户输入内容、切换类型、选择分类或添加记录时,状态数据都会发生变化。ArkUI 会根据最新状态自动刷新页面。


3. 添加账单记录

添加账单通过 addRecord 方法实现:

private addRecord(): void {
  let amount: number = Number(this.amountText)

  if (this.amountText.trim().length === 0 || amount <= 0) {
    return
  }

  let note: string = this.noteText.trim()

  if (note.length === 0) {
    note = '无备注'
  }

  let record: BillRecord = {
    id: this.nextId,
    amount: amount,
    type: this.recordType,
    category: this.category,
    note: note,
    time: this.getCurrentTime()
  }

  this.records = [record, ...this.records]
  this.nextId++
  this.amountText = ''
  this.noteText = ''
}

这里先将输入框中的金额转换为数字。如果金额为空、为 0 或为负数,就不添加记录。

如果备注为空,则默认设置为“无备注”。这样可以避免账单卡片中出现空白信息。

新增记录会放到数组最前面,方便用户立即看到刚添加的账单。


4. 统计收入总额

收入统计通过遍历账单数组实现:

private getIncomeTotal(): number {
  let total: number = 0

  this.records.forEach((item: BillRecord) => {
    if (item.type === '收入') {
      total += item.amount
    }
  })

  return total
}

这段代码只统计 type 为“收入”的账单。页面顶部的收入卡片会根据这个方法的返回值显示最新收入总额。


5. 统计支出总额

支出统计逻辑和收入统计类似:

private getExpenseTotal(): number {
  let total: number = 0

  this.records.forEach((item: BillRecord) => {
    if (item.type === '支出') {
      total += item.amount
    }
  })

  return total
}

这段代码只统计 type 为“支出”的账单。添加或删除支出记录后,支出总额会同步变化。


6. 计算当前结余

结余通过收入总额减去支出总额得到:

private getBalance(): number {
  return this.getIncomeTotal() - this.getExpenseTotal()
}

如果收入大于支出,结余为正数;如果支出大于收入,结余为负数。

页面中还根据结余是否为正设置了不同颜色:

private getBalanceColor(): string {
  if (this.getBalance() >= 0) {
    return '#10B981'
  }

  return '#EF4444'
}

这样可以让用户更直观地看到当前资金状态。


7. 筛选账单记录

账单筛选通过 filterTypegetFilteredRecords 方法实现:

private getFilteredRecords(): BillRecord[] {
  if (this.filterType === '收入') {
    return this.records.filter((item: BillRecord) => item.type === '收入')
  }

  if (this.filterType === '支出') {
    return this.records.filter((item: BillRecord) => item.type === '支出')
  }

  return this.records
}

筛选规则如下:

筛选条件 显示内容
全部 显示所有账单
收入 只显示收入记录
支出 只显示支出记录

筛选不会破坏原始账单数组,只是改变页面当前展示的内容。


8. 删除账单记录

删除记录通过 deleteRecord 方法实现:

private deleteRecord(id: number): void {
  this.records = this.records.filter((item: BillRecord) => item.id !== id)
}

这里使用 id 删除账单,而不是使用金额或备注。因为不同账单可能金额相同,也可能备注相同,使用唯一编号更安全。

删除后,账单列表、收入统计、支出统计和结余都会同步更新。


9. 空状态提示

当筛选后的账单记录为空时,页面会显示空状态提示:

if (this.getFilteredRecords().length === 0) {
  Column() {
    Text('暂无账单记录')
    Text('输入金额和备注后,点击添加记录开始记账。')
  }
}

这样可以避免列表区域空白,让用户知道当前没有可展示的数据。

空状态提示在真实应用中很重要。没有数据时不应该只留白,而应该告诉用户当前状态和下一步操作。


10. 使用 @Builder 封装页面结构

本项目中使用 @Builder 封装了多个页面片段:

@Builder
StatCard(title: string, value: string, color: string)

用于构建统计卡片。

@Builder
TypeButton(text: string)

用于构建收入和支出类型按钮。

@Builder
CategoryButton(text: string)

用于构建分类按钮。

@Builder
FilterButton(text: string)

用于构建筛选按钮。

@Builder
RecordCard(item: BillRecord)

用于构建账单记录卡片。

这样可以减少 build() 方法中的代码堆叠,让页面结构更加清晰,也方便后续维护。


九、运行项目

代码编写完成后,在 DevEco Studio 中选择模拟器或真机运行项目。

运行成功后,页面会显示“校园记账本”。用户可以输入金额和备注,选择收入或支出类型,再选择账单分类,点击“添加记录”即可生成新的账单。

可以按照以下步骤进行测试:

  1. 打开应用,检查页面是否正常显示;
  2. 查看默认账单记录是否显示;
  3. 查看收入、支出和结余统计是否正确;
  4. 输入金额,例如 18;
  5. 输入备注,例如 晚饭;
  6. 选择支出类型;
  7. 选择餐饮分类;
  8. 点击“添加记录”,检查账单是否新增成功;
  9. 切换到收入类型,添加一条收入记录;
  10. 点击“收入”筛选,检查是否只显示收入;
  11. 点击“支出”筛选,检查是否只显示支出;
  12. 删除某条记录,检查统计数据是否同步更新;
  13. 点击“清空”,检查账单列表是否清空;
  14. 清空后检查空状态提示是否显示。

测试结果如下:

测试功能 测试结果
页面正常显示 成功
默认账单加载 成功
添加支出记录 成功
添加收入记录 成功
收入统计 成功
支出统计 成功
结余计算 成功
分类选择 成功
账单筛选 成功
删除记录 成功
清空记录 成功
空状态提示 成功

经过测试,应用主要功能可以正常运行,页面状态也能根据用户操作及时刷新。


十、开发中遇到的问题

1. 金额输入需要校验

金额输入来自 TextInput,获取到的是字符串。计算前需要先转换为数字:

let amount: number = Number(this.amountText)

如果用户没有输入金额,或者输入金额小于等于 0,就不应该添加记录:

if (this.amountText.trim().length === 0 || amount <= 0) {
  return
}

这样可以避免无效账单进入列表。


2. 备注为空时需要默认值

如果用户没有输入备注,账单卡片中可能出现空白内容。因此项目中加入了默认备注:

if (note.length === 0) {
  note = '无备注'
}

这样即使用户没有填写备注,页面也能保持完整显示。


3. 统计金额不能写死

收入、支出和结余不能直接写固定数字,否则添加或删除记录后数据会不一致。

因此本项目通过遍历 records 数组动态计算:

this.records.forEach((item: BillRecord) => {
  if (item.type === '收入') {
    total += item.amount
  }
})

这样可以保证统计结果始终来自真实账单记录。


4. 删除记录要使用唯一编号

如果使用金额或备注删除账单,可能会误删其他记录。因为两条账单可能金额相同,也可能备注相同。

因此项目中使用 id 作为唯一编号:

item.id !== id

这样可以准确删除当前点击的那一条账单。


5. 筛选不能破坏原始数组

筛选收入或支出时,不能直接修改原始 records 数组。否则切换回“全部”时,其他记录可能已经丢失。

因此项目中使用 getFilteredRecords() 返回筛选结果,而不是直接改变 records 本身。


6. 空列表不能只显示空白

当账单记录为空时,如果页面什么都不显示,用户可能会以为页面出错。因此项目中增加了空状态提示:

Text('暂无账单记录')
Text('输入金额和备注后,点击添加记录开始记账。')

这样可以让页面在无数据时仍然保持完整。


十一、总结

本文基于 HarmonyOS 和 ArkTS 实现了一个校园记账本应用。项目通过 @State 管理页面数据,使用 ArkUI 组件完成页面布局,并实现了账单添加、收入支出统计、结余计算、分类选择、账单筛选、删除记录、清空记录和空状态提示等功能。

通过本次实践,主要完成了以下内容:

  • 使用 @Entry@Component 创建页面组件;
  • 使用 @State 保存输入内容、筛选条件和账单数据;
  • 使用 TextInput 获取金额和备注;
  • 使用 Button 实现类型切换、分类选择、添加记录和删除操作;
  • 使用 ListForEach 渲染账单列表;
  • 使用数组遍历统计收入和支出;
  • 使用 filter() 删除指定记录和筛选账单;
  • 使用条件渲染显示空状态;
  • 使用 @Builder 封装页面局部结构;
  • 完成一个可以运行的 HarmonyOS 账单管理工具页面。

这个项目虽然是一个单页面练习,但完整展示了 ArkTS 页面开发中的输入处理、数据记录、状态更新、列表渲染、动态统计和条件展示流程。

后续可以在这个基础上继续扩展,例如:

  • 增加本地数据持久化;
  • 增加月份筛选功能;
  • 增加预算提醒;
  • 增加分类自定义;
  • 增加账单编辑功能;
  • 增加消费趋势统计;
  • 增加图表展示;
  • 增加深色模式适配;
  • 增加搜索账单功能;
  • 增加导出账单功能。

整体来看,校园记账本应用非常适合作为 HarmonyOS ArkTS 的练习项目。它功能清晰、代码量适中、运行效果直观,能够帮助初学者理解 ArkUI 声明式开发和 @State 状态驱动页面刷新的基本思想。

Logo

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

更多推荐