《ArkUI 记账本开发:状态管理与数据持久化实现》

效果展示:

记账本应用功能分析笔记

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: '' })
      })
  }
}
相关推荐
MediaTea31 分钟前
Python 第三方库:lxml(高性能 XML/HTML 解析与处理)
xml·开发语言·前端·python·html
西陵36 分钟前
Nx带来极致的前端开发体验——使用MF进行增量构建
前端·javascript·架构
编啊编程啊程1 小时前
响应式编程框架Reactor【2】
java
Nicholas681 小时前
flutter滚动视图之ProxyWidget、ProxyElement、NotifiableElementMixin源码解析(九)
前端
编啊编程啊程1 小时前
响应式编程框架Reactor【3】
java·开发语言
Ka1Yan1 小时前
什么是策略模式?策略模式能带来什么?——策略模式深度解析:从概念本质到Java实战的全维度指南
java·开发语言·数据结构·算法·面试·bash·策略模式
JackieDYH1 小时前
vue3中reactive和ref如何使用和区别
前端·javascript·vue.js
伍哥的传说1 小时前
解密 Vue 3 shallowRef:浅层响应式 vs 深度响应式的性能对决
javascript·vue.js·ecmascript·vue3.js·大数据处理·响应式系统·shallowref
你我约定有三2 小时前
面试tips--java--equals() & hashCode()
java·开发语言·jvm
ZZHow10242 小时前
React前端开发_Day4
前端·笔记·react.js·前端框架·web