效果展示:

记账本应用功能分析笔记
1. 页面整合
- BillAddPage.ets: 添加记账页面
- BillIndexPage.ets: 首页账单展示
- BillData.ets: 数据结构和枚举定义
2. 添加页-支出收入高亮切换
需求: 点击切换分类高亮效果,结合BillType枚举完成
实现思路:
- 用
@State ActiveBill: BillType
存储当前选中的账单类型 - 点击时修改枚举值,UI根据枚举值动态切换样式
- 支出/收入切换时重置默认分类选择
(没有渲染的数据,也没有下标去选择,用枚举或者也可以自己创建一个类型去判断)

代码实现:
@State ActiveBill: BillType = BillType.Pay // 用枚举当做高亮的判断条件
// 点击事件中修改枚举值
.onClick(() => {
this.ActiveBill = BillType.Pay // 支出的高亮判断
this.selectItem = { id: 1, icon: $r('app.media.food'), name: '餐费' }
})
// 根据枚举值切换高亮
.backgroundColor(this.ActiveBill === BillType.Pay ? Color.Black : '#fff')
.fontColor(this.ActiveBill === BillType.Pay ? '#fff' : '#000')


3. 添加页-分类渲染
需求: 根据不同的账单类型,底部渲染不同的列表
实现思路:
- 定义两个分类数组:
payList
(支出分类)和incomeList
(收入分类) - 根据
ActiveBill
的值,用三元运算符选择渲染哪个分类列表 - 使用
ForEach
遍历渲染分类标题和项目
代码实现:
// 支出和收入分类列表
@State payList: UseForCategory[] = payBillCategoryList
@State incomeList: UseForCategory[] = inComBillCategoryList
// 根据当前选择的账单类型渲染对应分类
ForEach(this.ActiveBill === BillType.Pay ? this.payList : this.incomeList,
(item: UseForCategory, index: number) => {
// 渲染分类标题和项目
})

4. 添加页-分类高亮切换
需求: 点击切换分类的高亮效果,参考BillItem格式完成
实现思路:
- 用
@State selectItem
保存当前选中的分类项 - 点击分类时,将选中的项赋值给
selectItem
- UI样式根据
selectItem.id === ele.id
判断是否高亮
代码实现:
@State selectItem: UseForItem = { id: 1, icon: $r('app.media.food'), name: '餐费' }
// 根据保存的项和当前项id是否相等
.border({ width: 1, color: this.selectItem.id === ele.id ? '#5b8161' : Color.Transparent })
.backgroundColor(this.selectItem.id === ele.id ? '#dcf1e4' : Color.Transparent)
// 点击事件中保存选中的项
.onClick(() => {
this.selectItem = ele
})


5. 添加页-记账
需求: 数据持久化、添加数据、考虑金额为空、添加完毕提示、返回首页
实现思路:
- 用
@StorageLink
绑定持久化数据moneyList
- 保存时创建新的
BillItem
对象,支出用负数,收入用正数 - 使用
unshift
添加到数组开头,实现最新记录在前 - 保存后调用
PathStack.pop()
返回首页
代码实现:
// 用户输入的金额
@State inputMoney: string = ''
// 获取仓库
@StorageLink('moneyList')
moneyList: BillItem[] = []
// 保存按钮点击事件
.onClick(() => {
this.PathStack.pop() // 返回到首页
this.moneyList.unshift({
id: Date.now(),
type: this.ActiveBill,
money: this.ActiveBill === BillType.Pay ? -Number(this.inputMoney) : Number(this.inputMoney),
useFor: this.selectItem
})
})

6. 首页-渲染列表
需求: 渲染列表
实现思路:
- 使用
List
组件包裹,ForEach
遍历moneyList
数组 - 每个
ListItem
渲染一个DailyBillSection
组件 - 传递
item
数据给子组件进行具体渲染
代码实现:
// 使用数组和forEach渲染
List({ space: 10 }) {
ForEach(this.moneyList, (item: BillItem, index: number) => {
ListItem() {
DailyBillSection({ item: item })
}
})
}

7. 首页-删除数据
需求: 点击删除,删除对应数据,根据id删除
实现思路:
- 使用
swipeAction
实现左滑删除 - 删除函数用
filter
筛选出不等于指定id的项 - 通过
@Watch
监听数据变化,自动重新计算统计
代码实现:
// 滑动删除功能
.swipeAction({
end: this.delSection(item.id) // 用filter筛选出没有此id的,实现删除
})
// 删除函数
delSection(id: number) {
// 使用id筛选删除
this.moneyList = this.moneyList.filter(item => item.id !== id)
}

8. 首页-统计
需求: 统计支出、收入、结余,添加、删除、页面打开时均需要计算一次
现思路:
- 定义
@State zhiChu
和@State shouRu
存储统计结果 - 用
filter
按类型分组,reduce
计算总和 - 在
aboutToAppear
、添加、删除时都调用changMoney()
重新计算 - 结余 = 收入 - 支出
代码实现:
// 定义对应的State
@State zhiChu: number = 0 // 支出
@State shouRu: number = 0 // 收入
// 什么时候计算:添加时、删除时、清空时、Watch
@Watch('changMoney')
moneyList: BillItem[] = []
// 计算函数
changMoney() {
// 支出的总数
this.zhiChu = this.moneyList.filter(item => item.type === BillType.Pay)
.reduce((sum: number, ele: BillItem) => sum + ele.money, 0)
// 收入的总数
this.shouRu = this.moneyList.filter(item => item.type === BillType.InCome)
.reduce((sum: number, ele: BillItem) => sum + ele.money, 0)
}
// 一进页面就加载获取数据,调用函数
aboutToAppear(): void {
this.changMoney()
}

核心数据结构
// 账单类型枚举
export enum BillType {
Pay, // 支出
InCome // 收入
}
// 账单项接口
export interface BillItem {
id: number
type: BillType
money: number
useFor: UseForItem
}
// 分类项目接口
export interface UseForItem {
id: number
icon: ResourceStr
name: string
}
数据持久化
实现思路:
-
使用
PersistentStorage.persistProp
初始化数据仓库 -
@StorageLink
和@StorageProp
实现组件间的数据同步 -
数据变更自动保存到本地存储
// 初始化仓库
PersistentStorage.persistProp('moneyList', [])// 使用@StorageLink和@StorageProp进行数据绑定
@StorageLink('moneyList')
@StorageProp('moneyList')
全部代码:
// 账单类型
export interface UseForCategory {
title: string
items: UseForItem[]
}
// 账单项
export interface UseForItem {
id: number
icon: ResourceStr
name: string
}
// 支付类型 枚举
export enum BillType {
Pay,
InCome
}
// 订单
export interface BillItem {
id: number
type: BillType
money: number
useFor: UseForItem
}
// 支出的类型分类
export const payBillCategoryList: UseForCategory[] = [
{
title: '餐饮',
items: [
{
id: 1, icon: $r('app.media.food'), name: '餐费'
},
{
id: 2, icon: $r('app.media.drinks'), name: '酒水饮料'
},
{
id: 3, icon: $r('app.media.dessert'), name: '甜品零食'
},
]
},
{
title: '出行交通',
items: [
{
id: 4, icon: $r('app.media.taxi'), name: '打车租车'
},
{
id: 5, icon: $r('app.media.longdistance'), name: '旅行票费'
},
]
},
{
title: '休闲娱乐',
items: [
{
id: 6, icon: $r('app.media.bodybuilding'), name: '运动健身'
},
{
id: 7, icon: $r('app.media.game'), name: '休闲玩乐'
},
{
id: 8, icon: $r('app.media.audio'), name: '媒体影音'
},
{
id: 9, icon: $r('app.media.travel'), name: '旅游度假'
},
],
},
{
title: '日常支出',
items: [
{
id: 10, icon: $r('app.media.clothes'), name: '衣服裤子'
},
{
id: 11, icon: $r('app.media.bag'), name: '鞋帽包包'
},
{
id: 12, icon: $r('app.media.book'), name: '知识学习'
},
{
id: 13, icon: $r('app.media.promote'), name: '能力提升'
},
{
id: 14, icon: $r('app.media.home'), name: '家装布置'
},
],
},
{
title: '其他支出',
items: [{ id: 15, icon: $r('app.media.community'), name: '社区缴费' }]
}
]
// 收入的类型分类
export const inComBillCategoryList: UseForCategory[] = [
{
title: '个人收入',
items: [
{ id: 16, icon: $r('app.media.salary'), name: '工资' },
{ id: 17, icon: $r('app.media.overtimepay'), name: '加班' },
{ id: 18, icon: $r('app.media.bonus'), name: '奖金' },
],
},
{
title: '其他收入',
items: [
{ id: 19, icon: $r('app.media.financial'), name: '理财收入' },
{ id: 20, icon: $r('app.media.cashgift'), name: '礼金收入' },
],
},
]
import {
BillItem,
BillType,
inComBillCategoryList,
payBillCategoryList,
UseForCategory,
UseForItem
} from '../Bill/Data/BillData'
@Builder
function AddPageBuilder() {
NavDestination() {
Bill_AddPage()
}.title('记一笔')
.backButtonIcon($r('app.media.ic_public_arrow_left'))
.backgroundColor('#d7f1e2')
}
@Entry
@Component
struct Bill_AddPage {
// 存储点击的支出或收入做高亮效果
@State ActiveBill: BillType = BillType.Pay //用枚举当做高亮的判断条件
@Consume PathStack: NavPathStack
// @Link ActiveBill: BillType
//支出
@State payList: UseForCategory[] = payBillCategoryList
//收入
@State incomeList: UseForCategory[] = inComBillCategoryList
// 分类的高亮
@State selectItem: UseForItem = { id: 1, icon: $r('app.media.food'), name: '餐费' }
//用户输入的金额
@State inputMoney: string = ''
// 获取仓库
@StorageLink('moneyList')
moneyList: BillItem[] = []
build() {
Column() {
// 切换订单类型
switchBillTypeBuilder({ ActiveBill: this.ActiveBill, selectItem: this.selectItem })
// 输入框区域
Stack({ alignContent: Alignment.End }) {
Row() {
// 输入框
TextInput({ placeholder: '0.00', text: $$this.inputMoney })
.layoutWeight(1)
.textAlign(TextAlign.End)
.backgroundColor(Color.Transparent)
.fontColor(Color.Gray)
.type(InputType.NUMBER_DECIMAL)// 只能输入数值
.fontSize(20)
.placeholderFont({ size: 20 })
.placeholderColor('#d9d9d9')
.padding({ right: 20 })
}
.width('100%')
Text('¥')
.fontSize(25)
}
.backgroundColor(Color.White)
.width('80%')
.margin(20)
.borderRadius(10)
.border({ width: 1, color: Color.Gray })
.padding({ left: 10, right: 10 })
// 订单
Column({ space: 10 }) {
ForEach(this.ActiveBill === BillType.Pay ? this.payList : this.incomeList,
(item: UseForCategory, index: number) => {
Column({ space: 20 }) {
Text(item.title)
.alignSelf(ItemAlign.Start)
.fontColor(Color.Gray)
.fontSize(14)
Row({ space: 10 }) {
ForEach(item.items, (ele: UseForItem) => {
IconCom({ ele: ele })
.borderRadius(5)// 选中的高亮样式
.border({ width: 1, color: this.selectItem.id === ele.id ? '#5b8161' : Color.Transparent })
.backgroundColor(this.selectItem.id === ele.id ? '#dcf1e4' : Color.Transparent)
.onClick(() => {
this.selectItem = ele
})
// IconCom()
// .borderRadius(5)// 默认的样式
// .border({ width: 1, color: Color.Transparent })
// .backgroundColor(Color.Transparent)
})
}
.alignSelf(ItemAlign.Start)
}
.width('100%')
})
}
.padding(15)
.width('100%')
.borderRadius(25)
.layoutWeight(1)
.backgroundColor(Color.White)
Blank()
Button('保 存')
.width('80%')
.type(ButtonType.Capsule)
.backgroundColor(Color.Transparent)
.fontColor('#5b8161')
.border({ width: 1, color: '#5b8161' })
.margin(10)
.onClick(() => {
this.PathStack.pop() //返回到首页
// 添加数据
this.moneyList.unshift({
id: Date.now(),
type: this.ActiveBill,
money: this.ActiveBill === BillType.Pay ? -Number(this.inputMoney) : Number(this.inputMoney),
useFor: this.selectItem
})
})
}
.height('100%')
}
}
@Component
struct switchBillTypeBuilder {
@Link ActiveBill: BillType
@Link selectItem: UseForItem
build() {
// 切换订单类型
Row({ space: 15 }) {
// 选中时 文本白色,背景黑色
Text('支出')
.tabTextExtend()
.backgroundColor(this.ActiveBill === BillType.Pay ? Color.Black : '#fff')
.fontColor(this.ActiveBill === BillType.Pay ? '#fff' : '#000')
.onClick(() => {
this.ActiveBill = BillType.Pay //支出的高亮判断
this.selectItem = { id: 1, icon: $r('app.media.food'), name: '餐费' }
})
Text('收入')
.tabTextExtend()
.backgroundColor(this.ActiveBill === BillType.InCome ? Color.Black : '#fff')
.fontColor(this.ActiveBill === BillType.InCome ? '#fff' : '#000')
.onClick(() => {
this.ActiveBill = BillType.InCome //收入的高亮判断
this.selectItem = { id: 16, icon: $r('app.media.salary'), name: '工资' }
})
}
}
}
@Component
struct IconCom {
@Prop ele: UseForItem
build() {
Column({ space: 5 }) {
Image(this.ele.icon)
.width(20)
Text(this.ele.name)
.fontSize(12)
.width(48)
.textAlign(TextAlign.Center)
}
.padding(5)
}
}
@Extend(Text)
function tabTextExtend() {
.borderRadius(15)
.width(50)
.height(30)
.textAlign(TextAlign.Center)
.fontSize(14)
}
import { BillItem, BillType } from './Data/BillData'
// 初始化仓库
PersistentStorage.persistProp('moneyList', [])
@Entry
@Component
struct Billd_IndexPage {
@Provide PathStack: NavPathStack = new NavPathStack()
//取出数据
@StorageLink('moneyList')
@Watch('changMoney')
moneyList: BillItem[] = []
// 支出的总数
@State zhiChu:number=0
// 收入的总数
@State shouRu:number=0
// 一进页面就加载获取数据,调用函数
aboutToAppear(): void {
this.changMoney()
}
changMoney(){
// 支出的总数
this.zhiChu=this.moneyList.filter(item=>item.type===BillType.Pay)
.reduce((sum:number,ele:BillItem)=>sum+ele.money,0)
// 收入的总数
this.shouRu=this.moneyList.filter(item=>item.type===BillType.InCome)
.reduce((sum:number,ele:BillItem)=>sum+ele.money,0)
}
build() {
Navigation(this.PathStack) {
Stack({ alignContent: Alignment.BottomEnd }) {
Column({ space: 10 }) {
// 顶部区域
Column({ space: 30 }) {
Text('账单合计')
.fontSize(25)
.width('100%')
Row() {
BillInfo({
billName: '支出',
billNum: this.zhiChu.toFixed(2).toString()
})
BillInfo({
billName: '收入',
billNum: this.shouRu.toFixed(2).toString()
})
BillInfo({
billName: '结余',
billNum: `${(this.shouRu-this.zhiChu).toFixed(2)}`
})
}
}
.width('100%')
.height(140)
.backgroundImage($r('app.media.bill_title_bg'))
.backgroundImageSize({ width: '100%', height: '100%' })
.padding(20)
// 账单区域
List({ space: 10 }) {
ForEach(this.moneyList, (item: BillItem,index:number) => {
ListItem() {
DailyBillSection({ item: item })
}
.swipeAction({
// end: this.delSection(index)//用下标删除
end: this.delSection(item.id)//用filter筛选出没有此id的,实现删除
})
})
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.padding(10)
.width('100%')
// 添加按钮
AddButton()
}
.height('100%')
.backgroundColor('#f6f6f6')
}
}
// 删除标签
@Builder
// delSection(index:number) {
delSection(id:number) {
Image($r('app.media.ic_public_delete_filled'))
.fillColor('#ec6073')
.width(30)
.margin(5)
.onClick(()=>{
//this.moneyList= this.moneyList.splice(index,1)//使用下标删除
this.moneyList=this.moneyList.filter(item=>item.id!==id)//使用id筛选删除
})
}
}
@Component
struct BillInfo {
@Prop billName: string = ''
@Prop billNum: string = ''
build() {
Column({ space: 10 }) {
Text(this.billNum)
.fontSize(20)
Text(this.billName)
.fontSize(12)
}
.layoutWeight(1)
// .alignItems(HorizontalAlign.Start)
}
}
@Component
struct DailyBillSection {
@Prop item: BillItem
build() {
// 分割线
Row({ space: 10 }) {
Image(this.item.useFor.icon)
.width(20)
Text('餐费')
Blank()
Text(this.item.money.toString())
.fontColor(this.item.money > 0 ? '#000' : '#ff8c7b')
// 根据是否为支付调整颜色
// 支付:#ff8c7b
// 收入:Color.Black
}
.width('100%')
.borderRadius(10)
.padding(15)
.backgroundColor(Color.White)
}
}
@Component
struct AddButton {
@Consume PathStack: NavPathStack
build() {
Image($r('app.media.ic_public_add_filled'))
.width(40)
.fillColor('#8e939d')
.padding(5)
.borderRadius(20)
.border({ width: 1, color: '#8e939d' })
.translate({ x: -20, y: -20 })
.backgroundColor('#f6f6f6')
.onClick(() => {
// 这是router的方法!!
// this.PathStack.pushPath({
// url:'pages/Bill/Data/BillAddPage'
// })
// this.PathStack.pushPathByName('Bill_AddPage',null)
this.PathStack.pushPath({ name: 'Bill_AddPage', param: '' })
})
}
}