在这里插入图片描述
在这里插入图片描述

实例:预算管理(Budget)|收官文章

一、文件清单

文件 职责 行数
database/BudgetDao.ets 数据层:双表、分类聚合、进度计算、6 类预算 + 25 笔支出种子 约 310 行
pages/samples/BudgetPage.ets UI 层:总进度卡 + 分类进度条 + 支出明细 + 记支出弹窗 约 225 行
resources/base/profile/main_pages.json 路由注册:pages/samples/BudgetPage 追加一行
pages/Index.ets 首页入口按钮 追加一个按钮

本篇文章完整展示可编译运行的 BudgetPage 代码,最后描述运行效果。

二、BudgetPage 完整代码

import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { BudgetDao, BudgetWithSpend, Expense } from '../../database/BudgetDao';

@Entry
@Component
struct BudgetPage {
  @State budgets: BudgetWithSpend[] = [];
  @State expenses: Expense[] = [];
  @State budgetTotal: number = 0;
  @State spendTotal: number = 0;
  @State selectedCategory: string = '餐饮';
  @State addVisible: boolean = false;
  @State fAmount: string = '';
  @State fNote: string = '';
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;

  aboutToAppear(): void {
    this.refresh();
  }

  private currentMonth(): string {
    const now = new Date();
    return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
  }

  async refresh(): Promise<void> {
    try {
      await BudgetDao.initSeedData(this.context);
      this.budgets = await BudgetDao.queryWithSpend(this.context, this.currentMonth());
      const t = await BudgetDao.totalForMonth(this.context, this.currentMonth());
      this.budgetTotal = t.budgetTotal;
      this.spendTotal = t.spendTotal;
      if (this.budgets.length > 0) {
        this.selectedCategory = this.budgets[0].budget.category;
      }
      await this.loadExpenses();
    } catch (e) {
      promptAction.showToast({ message: `加载失败: ${e}` });
    }
  }

  async loadExpenses(): Promise<void> {
    this.expenses = await BudgetDao.queryExpensesByCategory(this.context, this.selectedCategory);
  }

  async switchCategory(cat: string): Promise<void> {
    this.selectedCategory = cat;
    await this.loadExpenses();
  }

  async onAdd(): Promise<void> {
    const amount = parseFloat(this.fAmount);
    if (!amount || amount <= 0) {
      promptAction.showToast({ message: '请输入有效金额' });
      return;
    }
    const expense: Expense = {
      id: 0,
      category: this.selectedCategory,
      amount: amount,
      note: this.fNote.trim(),
      spendTime: Date.now(),
    };
    await BudgetDao.insertExpense(this.context, expense);
    this.addVisible = false;
    this.fAmount = ''; this.fNote = '';
    await this.refresh();
    promptAction.showToast({ message: '✅ 支出已记录' });
  }

  onDelete(id: number): void {
    BudgetDao.deleteExpense(this.context, id).then(async () => {
      await this.refresh();
      promptAction.showToast({ message: '🗑 已删除' });
    });
  }

  /** 计算总进度百分比 */
  private totalPercent(): number {
    if (this.budgetTotal <= 0) {
      return 0;
    }
    return Math.min(100, Math.round(this.spendTotal / this.budgetTotal * 100));
  }

  private fmtTime(ts: number): string {
    const d = new Date(ts);
    return `${d.getMonth() + 1}${d.getDate()}`;
  }

  build() {
    Column() {
      // ===== 标题栏 =====
      Row() {
        Column() {
          Text('💹 预算管理').fontSize(22).fontWeight(FontWeight.Bold)
          Text(`${this.currentMonth()} · 预算 vs 实际`).fontSize(12).fontColor('#999999').margin({ top: 2 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Text('↻').fontSize(22).onClick(() => this.refresh())
      }.width('100%').padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column() {
          // ===== 总预算进度卡 =====
          Column() {
            Text('本月总支出进度').fontSize(13).fontColor('#9CA3AF')
            Stack({ alignContent: Alignment.Center }) {
              Row().width(110).height(110).borderRadius(55).backgroundColor('#1F2937')
              Column() {
                Text(`${this.totalPercent()}%`).fontSize(28).fontWeight(FontWeight.Bold).fontColor(Color.White)
                Text(`¥${this.spendTotal.toFixed(0)} / ¥${this.budgetTotal.toFixed(0)}`).fontSize(11).fontColor('#9CA3AF').margin({ top: 4 })
              }
            }
            Row() {
              Row() {
                Row().width('100%').height(10).borderRadius(5)
                  .backgroundColor(this.totalPercent() > 100 ? '#EF4444' : '#4ADE80')
              }
              .width(`${this.totalPercent()}%`)
              .height(10).borderRadius(5)
            }
            .width('80%').height(10).borderRadius(5).backgroundColor('#374151').margin({ top: 12 })
            Text(this.totalPercent() > 100 ? '⚠️ 已超支,请控制开支!' : `剩余可用 ¥${(this.budgetTotal - this.spendTotal).toFixed(2)}`)
              .fontSize(13).fontWeight(FontWeight.Medium)
              .fontColor(this.totalPercent() > 100 ? '#F87171' : '#4ADE80')
              .margin({ top: 10 })
          }
          .width('94%').padding(18)
          .borderRadius(18)
          .linearGradient({ angle: 180, colors: [['#0F172A', 0], ['#1E293B', 1]] })
          .margin({ top: 10 })

          // ===== 分类预算明细 =====
          Column() {
            Text('分类预算明细').fontSize(16).fontWeight(FontWeight.Bold)
            ForEach(this.budgets, (bw: BudgetWithSpend) => {
              Column() {
                Row() {
                  Text(bw.budget.category).fontSize(14).fontWeight(FontWeight.Medium)
                  Text(bw.overrun ? '超支!' : `${bw.percent}%`)
                    .fontSize(11).padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(8)
                    .backgroundColor(bw.overrun ? '#FEE2E2' : '#E5F6EC')
                    .fontColor(bw.overrun ? '#DC2626' : '#059669')
                  Blank()
                  Text(`¥${bw.spent.toFixed(0)} / ¥${bw.budget.amount.toFixed(0)}`).fontSize(12).fontColor('#6B7280')
                }.width('100%')
                Row() {
                  Row() {
                    Row().width('100%').height(10).borderRadius(5)
                      .backgroundColor(bw.overrun ? '#EF4444' : bw.budget.color)
                  }
                  .width(`${bw.percent}%`)
                  .height(10).borderRadius(5)
                }
                .width('100%').height(10).borderRadius(5).backgroundColor('#E5E7EB').margin({ top: 6 })
              }
              .width('100%').padding(12).backgroundColor(Color.White).borderRadius(10).margin({ top: 8 })
              .onClick(() => this.switchCategory(bw.budget.category))
            }, (bw: BudgetWithSpend) => `${bw.budget.id}-${bw.budget.category}`)
          }
          .width('94%').padding(16).backgroundColor(Color.White).borderRadius(16).margin({ top: 12 })
          .alignItems(HorizontalAlign.Start)

          // ===== 分类支出明细 =====
          Column() {
            Row() {
              Text(`${this.selectedCategory} 支出明细`).fontSize(16).fontWeight(FontWeight.Bold)
              Blank()
              Text('+').fontSize(22).fontColor('#3B82F6').onClick(() => {
                this.fAmount = ''; this.fNote = '';
                this.addVisible = true;
              })
            }.width('100%')
            if (this.expenses.length === 0) {
              Text('该分类暂无支出').fontSize(13).fontColor('#999999').margin({ top: 12 })
            } else {
              ForEach(this.expenses, (e: Expense) => {
                Row({ space: 10 }) {
                  Column({ space: 2 }) {
                    Text(`${e.amount.toFixed(2)}`).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#DC2626')
                    Text(`${this.fmtTime(e.spendTime)}${e.note ? ' · ' + e.note : ''}`).fontSize(11).fontColor('#999999')
                  }.alignItems(HorizontalAlign.Start).layoutWeight(1)
                  Text('✕').fontSize(14).fontColor('#CCCCCC').onClick(() => this.onDelete(e.id))
                }
                .width('100%').padding(12).backgroundColor(Color.White).borderRadius(10).margin({ top: 8 })
              }, (e: Expense) => `e-${e.id}`)
            }
          }
          .width('94%').padding(16).margin({ top: 12, bottom: 24 })
          .alignItems(HorizontalAlign.Start)
        }
      }
      .width('100%').layoutWeight(1).scrollBar(BarState.Off)

      // ===== 新增支出弹窗 =====
      if (this.addVisible) {
        Column() {
          Text(`💰 记一笔${this.selectedCategory}支出`).fontSize(18).fontWeight(FontWeight.Bold)
          TextInput({ placeholder: '金额(元)*', text: this.fAmount }).type(InputType.Number)
            .margin({ top: 12 }).onChange((v: string) => this.fAmount = v)
          TextInput({ placeholder: '备注(可选)', text: this.fNote })
            .margin({ top: 8 }).onChange((v: string) => this.fNote = v)
          Row({ space: 8 }) {
            Button('取消').layoutWeight(1).backgroundColor('#EEF2F7').fontColor('#555555')
              .onClick(() => this.addVisible = false)
            Button('保存').layoutWeight(1).backgroundColor('#3B82F6')
              .onClick(() => this.onAdd())
          }.margin({ top: 16 })
        }
        .padding(20).borderRadius(16).backgroundColor(Color.White).width('88%')
        .position({ x: '6%', y: '30%' })
      }
    }
    .width('100%').height('100%').backgroundColor('#F8FAFC')
  }
}

三、注册与运行

  1. main_pages.json 追加 "pages/samples/BudgetPage"
  2. Index.ets 追加:
    Button('💹 17 预算管理').fontSize(15).width('70%')
      .onClick(() => this.getUIContext().getRouter().pushUrl({ url: 'pages/samples/BudgetPage' }))
    
  3. 构建验证 → BUILD SUCCESSFUL

四、运行效果描述

进入「💹 17 预算管理」:

第一屏:标题栏「💹 预算管理 · 2024-06 · 预算 vs 实际」;深色渐变总预算卡——「本月总支出进度」+ 大数字「35%」+「¥2598 / ¥7500」+ 绿色进度条 + 「剩余可用 ¥4901.50」;「分类预算明细」6 张白卡——餐饮「42%」绿标签 + ¥623/¥1500 + 橙色进度条、购物「61%」+ 粉色进度条…;「餐饮 支出明细」9 笔流水(生日聚餐 -¥210.00 最顶、楼下快餐 -¥28.50 最底)。

交互一(超支预警闭环):点「购物」卡 → 明细变 6 笔(球鞋 -¥420.00 等)→ 点明细右上「+」→ 弹窗「💰 记一笔购物支出」→ 金额输 2000 → 保存 → Toast「✅ 支出已记录」→ 购物卡变「超支!」红标签、进度条变红、总进度 35%→61%、剩余可用减少 → 超支预警完整呈现。

交互二(分类联动):点「学习」卡 → 「学习 支出明细」1 笔(在线课程 -¥45.00)→ 点「住房」→ 1 笔(电费 -¥320.00)。

交互三(删除恢复):删除刚才的 2000 元支出 → 购物恢复「61%」绿、总进度回落。

交互四(金额校验):点「+」→ 金额留空点保存 → Toast「请输入有效金额」(!amount || amount <= 0 拦截)。

五、代码质量要点回顾

关注点 本实例做法
双表对照 budget 计划 + expense 流水
分类聚合 WHERE + SUM
进度计算 spent/amount + 封顶 + 除零守卫
超支判断 spent > amount 独立布尔
复合返回 BudgetWithSpend 实体+派生
进度条 双层 Row 百分比宽
金额校验 parseFloat + <= 0 拦截

六、文章小结

「预算管理」收官。五篇文章覆盖:双表对照建模(-1)→ 进度面板 UI(-2)→ 分类聚合与超支判断(-3)→ 6 类预算 + 25 笔支出种子(-4)→ 全量代码(-5)。核心技术是「预算 vs 实际」的双表对照结构(业务键 category 关联)、WHERE + SUM 条件聚合、进度/超支的派生计算(封顶 + 除零守卫 + 独立布尔)、双层 Row 自制进度条与超支红色高亮。这是「计划 vs 执行」类财务应用的完整范式。

Logo

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

更多推荐