HarmonyOS ArkTS 实战:实现一个校园记账本应用
项目效果
本文实现的是一个基于 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实现类型切换、分类选择和添加记录; - 使用数组保存账单记录;
- 使用
List和ForEach渲染账单列表; - 根据账单记录动态计算收入总额;
- 根据账单记录动态计算支出总额;
- 根据收入和支出计算当前结余;
- 支持按全部、收入、支出筛选账单;
- 支持删除单条账单记录;
- 支持一键清空全部账单;
- 使用空状态提示优化无数据页面;
- 使用
@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 文件中更方便理解。
如果后续继续扩展,可以考虑把统计卡片、账单卡片、分类按钮和输入区域拆分成独立组件。
六、核心实现思路
本项目的核心流程如下:
- 定义账单记录数据结构;
- 使用
@State保存金额输入、备注输入、账单类型、账单分类和账单列表; - 用户输入金额和备注;
- 用户选择收入或支出类型;
- 用户选择账单分类;
- 点击添加按钮后生成新的账单记录;
- 根据记录数组计算收入总额;
- 根据记录数组计算支出总额;
- 根据收入和支出计算当前结余;
- 使用
ForEach渲染账单列表; - 根据筛选条件展示全部、收入或支出记录;
- 点击删除按钮后删除指定记录;
- 点击清空按钮后清空全部记录;
- 当记录为空时显示空状态提示。
项目中最重要的状态变量如下:
@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. 筛选账单记录
账单筛选通过 filterType 和 getFilteredRecords 方法实现:
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 中选择模拟器或真机运行项目。
运行成功后,页面会显示“校园记账本”。用户可以输入金额和备注,选择收入或支出类型,再选择账单分类,点击“添加记录”即可生成新的账单。
可以按照以下步骤进行测试:
- 打开应用,检查页面是否正常显示;
- 查看默认账单记录是否显示;
- 查看收入、支出和结余统计是否正确;
- 输入金额,例如 18;
- 输入备注,例如 晚饭;
- 选择支出类型;
- 选择餐饮分类;
- 点击“添加记录”,检查账单是否新增成功;
- 切换到收入类型,添加一条收入记录;
- 点击“收入”筛选,检查是否只显示收入;
- 点击“支出”筛选,检查是否只显示支出;
- 删除某条记录,检查统计数据是否同步更新;
- 点击“清空”,检查账单列表是否清空;
- 清空后检查空状态提示是否显示。
测试结果如下:
| 测试功能 | 测试结果 |
|---|---|
| 页面正常显示 | 成功 |
| 默认账单加载 | 成功 |
| 添加支出记录 | 成功 |
| 添加收入记录 | 成功 |
| 收入统计 | 成功 |
| 支出统计 | 成功 |
| 结余计算 | 成功 |
| 分类选择 | 成功 |
| 账单筛选 | 成功 |
| 删除记录 | 成功 |
| 清空记录 | 成功 |
| 空状态提示 | 成功 |
经过测试,应用主要功能可以正常运行,页面状态也能根据用户操作及时刷新。
十、开发中遇到的问题
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实现类型切换、分类选择、添加记录和删除操作; - 使用
List和ForEach渲染账单列表; - 使用数组遍历统计收入和支出;
- 使用
filter()删除指定记录和筛选账单; - 使用条件渲染显示空状态;
- 使用
@Builder封装页面局部结构; - 完成一个可以运行的 HarmonyOS 账单管理工具页面。
这个项目虽然是一个单页面练习,但完整展示了 ArkTS 页面开发中的输入处理、数据记录、状态更新、列表渲染、动态统计和条件展示流程。
后续可以在这个基础上继续扩展,例如:
- 增加本地数据持久化;
- 增加月份筛选功能;
- 增加预算提醒;
- 增加分类自定义;
- 增加账单编辑功能;
- 增加消费趋势统计;
- 增加图表展示;
- 增加深色模式适配;
- 增加搜索账单功能;
- 增加导出账单功能。
整体来看,校园记账本应用非常适合作为 HarmonyOS ArkTS 的练习项目。它功能清晰、代码量适中、运行效果直观,能够帮助初学者理解 ArkUI 声明式开发和 @State 状态驱动页面刷新的基本思想。
更多推荐



所有评论(0)