公司动态
HarmonyOS NEXT 企业级记账APP:预算提醒与预算进度
预算提醒与预算进度本文是《HarmonyOS NEXT 企业级开发实战30篇打造智能记账APP》系列的第17篇对应 Git Tagv0.1.7。承接前序开发本篇实现预算进度可视化与阈值提醒BudgetViewModel在加载时计算usage进度与isOverrun状态BudgetCardProgressBar直观呈现预算消耗超支时弹出醒目红色告警条。重点讲解 ArkTS 中Prop属性命名与CustomComponent基类方法冲突的编译陷阱及解决方案。前言企业级应用的完整体验包含主动提醒与数据可视化两大能力。记账 APP 的预算模块需要回答两个核心问题本月预算还剩多少是否已经超支本章落地BudgetViewModel业务逻辑层与BudgetCard/ProgressBar组件为后续统计图表模块奠定基础。本文将带你设计Budget数据模型与阈值算法usage/isOverrun封装BudgetViewModel业务逻辑层封装ProgressBar进度条与BudgetCard预算卡组件集成到BudgetView页面完成数据闭环规避 ArkTSProp属性名冲突的编译陷阱企业级核心原则功能必须可视、可交互、可响应。参考 HarmonyOS NEXT 开发者文档 了解官方约定配合 ArkUI 通用组件 掌握自定义组件机制。一、需求分析1.1 功能介绍需求项说明核心功能计算本月预算进度usage与超支状态isOverrun卡片化展示预算消耗超支红色告警数据源BudgetRepository预算BillRepository本月账单交互方式点击预算卡弹出InputDialog设置预算视觉规范预算蓝#007AFF/超支红#FF3B30/收入绿/支出红组件属性ProgressBar采用barHeight而非height规避基类冲突1.2 业务流程用户进入预算页 ↓ BudgetViewModel.loadData() 加载本月预算 本月账单 ↓ 按 BillType.EXPENSE 汇总本月支出 totalExpense ↓ budget.refresh(totalExpense) → progress budget.usage() → isOverrun budget.isOverrun() ↓ BudgetCard 渲染预算卡 ProgressBar 进度条 ↓ isOverrun 为 true → 展示红色预算已超支告警条1.3 阈值分级设计预算进度usage()返回0~1的比例值超支时封顶为 1结合isOverrun()可划分三个告警等级进度区间等级颜色提示usage 0.8正常预算蓝预算充足0.8 ≤ usage 1接近预算预算蓝预算即将用完isOverrun()超支支出红预算已超支请注意控制消费实际项目中BudgetView在isOverrun为真时渲染红色告警条BudgetCard的ProgressBar颜色也会同步切红实现数据驱动 UI。二、ViewModel 业务层2.1 BudgetViewModel 完整源码实际项目中BudgetViewModel位于viewmodel/BudgetViewModel.ets采用英文类名ArkTS 强制要求类名、struct 名、文件名使用合法标识符中文标识符会导致编译失败。它聚合BudgetRepository与BillRepository两个数据源加载时计算进度与超支状态。// viewmodel/BudgetViewModel.ets import { BillRepository } from ../repository/BillRepository; import { BudgetRepository } from ../repository/BudgetRepository; import { BillType } from ../constants/BillType; import { Budget } from ../model/Budget; import { DateUtil } from ../utils/DateUtil; import { MoneyUtil } from ../utils/MoneyUtil; import { ToastUtil } from ../utils/ToastUtil; export class BudgetViewModel { currentMonth: string ; budget: Budget | null null; totalExpense: number 0; totalIncome: number 0; progress: number 0; isOverrun: boolean false; isLoading: boolean true; private budgetRepo BudgetRepository.getInstance(); private billRepo BillRepository.getInstance(); async loadData(): Promisevoid { this.isLoading true; this.currentMonth DateUtil.formatDate(Date.now()).substring(0, 7); this.budget await this.budgetRepo.findCurrentMonth(); const start DateUtil.monthStart(Date.now()); const end DateUtil.monthEnd(Date.now()); const bills await this.billRepo.findByDateRange(start, end); this.totalExpense bills.filter(b b.type BillType.EXPENSE).reduce((s, b) s b.money, 0); this.totalIncome bills.filter(b b.type BillType.INCOME).reduce((s, b) s b.money, 0); if (this.budget) { this.budget.refresh(this.totalExpense); this.progress this.budget.usage(); this.isOverrun this.budget.isOverrun(); } this.isLoading false; } async setBudget(amountCents: number): Promisevoid { if (amountCents 0) { ToastUtil.show(预算必须大于 0); return; } this.budget await this.budgetRepo.setMonthBudget(this.currentMonth, amountCents); await this.loadData(); ToastUtil.show(预算已设置); } }2.2 字段说明字段类型用途currentMonthstring当前月份格式YYYY-MMbudgetBudget | null本月预算对象未设置时为 nulltotalExpensenumber本月支出汇总分totalIncomenumber本月收入汇总分progressnumber预算进度0~1由budget.usage()计算isOverrunboolean是否超支由budget.isOverrun()计算isLoadingboolean加载态标志2.3 关键设计要点loadData()的执行顺序非常重要性能优化执行流程如下先取currentMonth再查budgetRepo.findCurrentMonth()避免重复计算月份用DateUtil.monthStart(Date.now())/monthEnd(Date.now())圈定本月时间范围一次性拉取本月全部账单再用filter分别求支出/收入汇总只查询一次数据库仅当budget非空时才调用refresh/usage/isOverrun避免空指针setBudget写入后立即await this.loadData()刷新保证 UI 与持久化数据一致注意DateUtil.monthStart()和monthEnd()需要传入时间戳参数无参写法monthStart()会导致 ArkTS 类型校验失败。这是模板代码中最常见的迁移错误之一。三、Budget 模型与阈值算法3.1 Budget 模型源码预算的核心算法封装在Budget模型中ViewModel 只负责调度不重复计算。这样阈值逻辑单一数据源便于测试与复用。// model/Budget.ets export class Budget { id: string ; month: string ; budget: number 0; used: number 0; remain: number 0; constructor(id?: string, month?: string, budget?: number) { if (id ! undefined) this.id id; if (month ! undefined) this.month month; if (budget ! undefined) this.budget budget; } refresh(used: number): void { this.used used; this.remain this.budget - this.used; } isOverrun(): boolean { return this.used this.budget; } usage(): number { if (this.budget 0) return 0; return Math.min(this.used / this.budget, 1); } }3.2 阈值算法解析方法返回值说明refresh(used)void刷新已用金额并同步计算remain剩余 预算 − 已用可为负isOverrun()booleanused budget即超支usage()numberused / budget封顶为 1避免进度条溢出 100%usage()的Math.min(..., 1)封顶是关键设计当used超过budget时进度条仍显示 100%而超支信息通过isOverrun()单独表达颜色切红。这样进度可视化与超支告警解耦互不干扰。四、BudgetRepository 数据层BudgetRepository基于BaseRepository实现单例仓储提供本月预算查询与按月设置能力。// repository/BudgetRepository.ets import { BaseRepository } from ./BaseRepository; import { Budget } from ../model/Budget; import { UUIDUtil } from ../utils/UUIDUtil; import { DateUtil } from ../utils/DateUtil; export class BudgetRepository extends BaseRepositoryBudget { private static instance: BudgetRepository; static getInstance(): BudgetRepository { if (!BudgetRepository.instance) { BudgetRepository.instance new BudgetRepository(); } return BudgetRepository.instance; } protected getStorageKey(): string { return budgets; } protected getEntityId(b: Budget): string { return b.id; } protected createEntity(data: ESObject): Budget { return Budget.fromObject(data); } async findCurrentMonth(): PromiseBudget | null { const month DateUtil.formatDate(Date.now()).substring(0, 7); const all await this.findAll(); return all.find(b b.month month) ?? null; } async setMonthBudget(month: string, budget: number): PromiseBudget { const all await this.findAll(); const existing all.find(b b.month month); if (existing) { existing.budget budget; await this.save(existing); return existing; } const newBudget new Budget(UUIDUtil.generate(), month, budget); await this.save(newBudget); return newBudget; } }setMonthBudget采用存在即更新、不存在即创建的 upsert 语义保证同月预算唯一。findCurrentMonth用?? null显式返回可空类型与 ViewModel 的budget: Budget | null类型吻合。五、ArkTS 编译陷阱width/height 属性名冲突5.1 问题复现在自定义组件中开发者常习惯把尺寸属性命名为Prop width和Prop height。然而在 ArkTS 编译时会报如下错误错误: Property width in type XXX is not assignable to the same property in base type CustomComponent. Type number is not assignable to type ((value: Length) XXX) number. 错误: Property height in type XXX is not assignable to the same property in base type CustomComponent.5.2 原因分析根本原因ArkUI 中所有Component装饰的struct都隐式继承自基类CustomComponent。该基类已经声明了width(value: Length)和height(value: Length)这两个链式布局方法用于设置组件宽高。当你在子组件中用Prop width: number声明同名属性时TypeScript 类型系统发现子类属性类型number与基类方法类型((value: Length) XXX) number不兼容从而报出上述编译错误。简单来说width和height在 ArkUI 中是保留的布局方法名不能作为Prop属性名使用。5.3 解决方案将Prop width/Prop height重命名为带业务前缀的属性名并同步更新组件内部所有引用// ❌ 错误写法与基类方法冲突 Prop width: number 300; Prop height: number 200; // ... Canvas(this.ctx).width(this.width).height(this.height) // ✅ 正确写法使用业务前缀避免冲突 Prop chartWidth: number 300; Prop chartHeight: number 200; // ... Canvas(this.ctx).width(this.chartWidth).height(this.chartHeight)5.4 项目中的真实应用ProgressBar 的 barHeight本项目的ProgressBar组件正是这一规范的真实落地。它没有使用Prop height而是用Prop barHeight命名进度条厚度从根源上规避了与CustomComponent.height()的冲突// ❌ 反面教材 Prop height: number 8; // 与基类冲突编译失败 Column().height(this.height) // ✅ ProgressBar 的真实写法 Prop barHeight: number 8; // 业务前缀 bar安全 Column().height(this.barHeight)5.5 命名规范建议场景不推荐推荐说明画布尺寸width/heightchartWidth/chartHeight避开基类方法名与本系列第 19/20/21 篇一致进度条厚度heightbarHeightProgressBar真实采用卡片尺寸width/heightcardWidth/cardHeight同理通用尺寸width/heightxxxWidth/xxxHeight一律加业务前缀最佳实践在 ArkUI 中凡是涉及自定义尺寸的Prop属性都应添加业务前缀从根源上规避与CustomComponent基类方法的命名冲突。本系列全部图表组件统一采用chartWidth/chartHeightProgressBar采用barHeight。六、通用组件封装6.1 ProgressBar 进度条组件ProgressBar用Stack叠加轨道与进度条两层Column通过百分比宽度与animation实现平滑过渡。注意它使用Prop barHeight而非height。// components/chart/ProgressBar.ets import { AppColors } from ../../theme/Colors; import { AppSpace } from ../../theme/Spacing; Component export struct ProgressBar { Prop progress: number 0; Prop color: string AppColors.Budget; Prop trackColor: string AppColors.Background; Prop barHeight: number 8; build() { Stack() { Column().width(100%).height(this.barHeight).backgroundColor(this.trackColor).borderRadius(this.barHeight / 2) Column().width(${Math.min(this.progress, 1) * 100}%).height(this.barHeight) .backgroundColor(this.color).borderRadius(this.barHeight / 2) .animation({ duration: 300, curve: Curve.EaseInOut }) } .width(100%).height(this.barHeight) } }6.2 ProgressBar 属性一览属性装饰器类型默认值说明progressPropnumber0进度0~1内部用Math.min封顶colorPropstringAppColors.Budget进度条前景色trackColorPropstringAppColors.Background轨道背景色barHeightPropnumber8进度条厚度规避height冲突6.3 BudgetCard 预算卡组件BudgetCard组合预算金额、ProgressBar进度条与已用/剩余明细并根据isOverrun动态切换颜色。// components/card/BudgetCard.ets import { Budget } from ../../model/Budget; import { ProgressBar } from ../chart/ProgressBar; import { AppColors } from ../../theme/Colors; import { AppFontSize, AppFontWeight } from ../../theme/Typography; import { AppSpace } from ../../theme/Spacing; import { MoneyUtil } from ../../utils/MoneyUtil; Component export struct BudgetCard { Prop budget: Budget new Budget(, , 0); Prop totalExpense: number 0; onTap: () void () {}; build() { Column() { Row() { Text(本月预算).fontSize(AppFontSize.SM).fontColor(AppColors.SecondaryText) Text(MoneyUtil.formatWithComma(this.budget.budget)) .fontSize(AppFontSize.XL).fontColor(AppColors.PrimaryText).fontWeight(AppFontWeight.Bold) .layoutWeight(1).textAlign(TextAlign.End) }.width(100%) ProgressBar({ progress: this.budget.usage(), color: this.budget.isOverrun() ? AppColors.Expense : AppColors.Budget, barHeight: 12 }).margin({ top: AppSpace.MD }) Row() { Column() { Text(已用).fontSize(AppFontSize.XS).fontColor(AppColors.SecondaryText) Text(¥${MoneyUtil.format(this.totalExpense)}) .fontSize(AppFontSize.MD).fontColor(this.budget.isOverrun() ? AppColors.Expense : AppColors.PrimaryText) }.alignItems(HorizontalAlign.Start) Column() { Text(剩余).fontSize(AppFontSize.XS).fontColor(AppColors.SecondaryText) Text(¥${MoneyUtil.format(Math.max(this.budget.remain, 0))}) .fontSize(AppFontSize.MD).fontColor(AppColors.Budget) }.alignItems(HorizontalAlign.Start).margin({ left: AppSpace.LG }) }.width(100%).margin({ top: AppSpace.SM }).justifyContent(FlexAlign.Start) } .width(100%).padding(AppSpace.CardPadding) .backgroundColor(AppColors.CardBackground).borderRadius(AppSpace.CardRadius) .onClick(() { this.onTap(); }) } }关键技术ProgressBar的color由this.budget.isOverrun() ? AppColors.Expense : AppColors.Budget决定超支时进度条与已用金额同步变红实现阈值告警的视觉一致性。七、页面实现7.1 BudgetView 完整源码BudgetView是预算 Tab 页负责调度BudgetViewModel加载数据、渲染BudgetCard、未设置预算时引导设置、超支时展示告警条。// components/tabs/BudgetView.ets import { BudgetCard } from ../card/BudgetCard; import { BudgetViewModel } from ../../viewmodel/BudgetViewModel; import { AppColors } from ../../theme/Colors; import { AppFontSize } from ../../theme/Typography; import { AppSpace } from ../../theme/Spacing; import { MoneyUtil } from ../../utils/MoneyUtil; import { RouterUtil } from ../../utils/RouterUtil; import { InputDialog } from ../dialog/InputDialog; Component struct BudgetView { State viewModel: BudgetViewModel new BudgetViewModel(); State isLoading: boolean true; aboutToAppear(): void { this.loadData(); } private async loadData(): Promisevoid { this.isLoading true; await this.viewModel.loadData(); this.isLoading false; } build() { Scroll() { Column() { Text(预算中心) .fontSize(20).fontWeight(FontWeight.Bold) .margin({ top: AppSpace.XL, bottom: AppSpace.MD }) .alignSelf(ItemAlign.Start) if (!this.isLoading) { if (this.viewModel.budget) { BudgetCard({ budget: this.viewModel.budget, totalExpense: this.viewModel.totalExpense, onTap: () { this.showBudgetInput(); } }) Row() { Column() { Text(本月支出).fontSize(AppFontSize.SM).fontColor(AppColors.SecondaryText) Text(¥${MoneyUtil.format(this.viewModel.totalExpense)}) .fontSize(AppFontSize.XL).fontColor(AppColors.Expense).fontWeight(FontWeight.Bold) .margin({ top: AppSpace.XS }) }.layoutWeight(1).alignItems(HorizontalAlign.Start) Column() { Text(本月收入).fontSize(AppFontSize.SM).fontColor(AppColors.SecondaryText) Text(¥${MoneyUtil.format(this.viewModel.totalIncome)}) .fontSize(AppFontSize.XL).fontColor(AppColors.Income).fontWeight(FontWeight.Bold) .margin({ top: AppSpace.XS }) }.layoutWeight(1).alignItems(HorizontalAlign.Start) } .width(100%) .padding(AppSpace.CardPadding) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) .margin({ top: AppSpace.MD }) if (this.viewModel.isOverrun) { Row() { Image($r(app.media.icon_expense)).width(20).height(20).fillColor(AppColors.Expense) Text(预算已超支请注意控制消费) .fontSize(AppFontSize.SM).fontColor(AppColors.Expense) .margin({ left: AppSpace.SM }) } .width(100%) .padding(AppSpace.CardPadding) .backgroundColor(AppColors.ExpenseLight) .borderRadius(AppSpace.CardRadius) .margin({ top: AppSpace.MD }) } } else { Column() { Image($r(app.media.icon_empty)).width(80).height(80).fillColor(AppColors.SecondaryText) Text(尚未设置本月预算).fontSize(16).fontColor(AppColors.PrimaryText).margin({ top: AppSpace.MD }) Text(设置预算合理规划每一笔支出).fontSize(12).fontColor(AppColors.SecondaryText).margin({ top: AppSpace.XS }) Button(设置预算) .margin({ top: AppSpace.LG }) .backgroundColor(AppColors.Budget) .onClick(() { this.showBudgetInput(); }) } .width(100%) .padding({ top: 80, bottom: 80 }) } } } .width(100%) .padding({ left: AppSpace.XL, right: AppSpace.XL }) } .width(100%).height(100%) .scrollBar(BarState.Off) .backgroundColor(AppColors.Background) } private showBudgetInput(): void { let dialogController new CustomDialogController({ builder: InputDialog({ title: 设置本月预算, placeholder: 请输入预算金额元, onConfirm: (value: string) { const amount parseFloat(value); if (isNaN(amount) || amount 0) { return; } const cents Math.round(amount * 100); this.viewModel.setBudget(cents).then(() { this.loadData(); }); }, onCancel: () {} }), autoCancel: true, alignment: DialogAlignment.Center }); dialogController.open(); } } export default BudgetView;7.2 页面状态分支BudgetView.build()内部按状态分流渲染避免空指针与无效 UIisLoading为真 → 暂不渲染内容由外层Scroll占位budget为空 → 渲染空态引导卡片与设置预算按钮budget非空 → 渲染BudgetCard 收支统计卡isOverrun为真 → 额外渲染红色超支告警条八、路由与集成8.1 路由配置BudgetView作为 Tab 组件挂载在MainView中无需单独注册Entry路由// main_pages.json { src: [ pages/MainView, pages/HomeView, pages/StatisticsView, pages/BudgetView, pages/ProfileView, pages/AddBillView, pages/EditBillView, pages/SearchView ] }8.2 MainView 集成BudgetView通过export default导出后在MainView的 Tab 内容区按currentIndex切换展示// pages/MainView.ets节选 import { BudgetView } from ../components/tabs/BudgetView; // 在 Stack 内容区按 currentIndex 切换 if (this.currentIndex TAB_BUDGET) { BudgetView() }九、最佳实践9.1 阈值告警扩展方向当前实现以应用内告警条呈现超支状态。如需更强的提醒能力可在loadData计算出isOverrun后扩展本地通知实际项目可在BudgetViewModel注入通知服务// 扩展思路基于现有 progress / isOverrun 字段 if (this.budget) { this.budget.refresh(this.totalExpense); this.progress this.budget.usage(); this.isOverrun this.budget.isOverrun(); // 阈值分级80% 接近、100% 超支 if (this.isOverrun) { // 超支红警可触发本地通知 } else if (this.progress 0.8) { // 接近预算黄警可触发本地通知 } }9.2 主题色响应// 预算卡颜色全部取自 AppColors深色模式切换时由 ThemeManager 刷新 AppStorage // BudgetCard 与 ProgressBar 自动响应主题变化重新渲染9.3 金额单位规范规范说明内部存储用分money/budget字段均为整数分避免浮点误差展示用元MoneyUtil.format将分转元并保留两位小数千分位MoneyUtil.formatWithComma输出1,234.56输入转分Math.round(amount * 100)元转分十、运行验证10.1 构建命令hvigorw assembleHap--modemodule-pproductdefault10.2 验证清单验证项预期结果进入预算页加载数据后展示预算卡与进度条未设预算显示空态引导与设置预算按钮设置预算InputDialog输入后持久化并刷新进度展示ProgressBar按比例填充300ms 平滑过渡超支告警isOverrun为真时进度条变红、展示红色告警条编译通过无width/height冲突报错十一、常见问题11.1 进度条不显示// 原因progress 未在 0~1 区间或 budget.budget 为 0 // 解决usage() 已对 budget0 返回 0 并封顶为 1确保 loadData 中调用 budget.refresh()11.2 设置预算后未刷新// 原因setBudget 内已 await loadData()但页面未重新取值 // 解决页面的 showBudgetInput 回调中 .then(() { this.loadData(); }) 再次刷新11.3 monthStart/monthEnd 类型错误// 原因模板代码写成无参 DateUtil.monthStart() // 解决必须传入时间戳 DateUtil.monthStart(Date.now()) const start DateUtil.monthStart(Date.now()); const end DateUtil.monthEnd(Date.now());十二、Git 提交12.1 提交命令gitadd.gitcommit-mfeat(预算): 预算提醒与预算进度 - 新增 BudgetViewModel 业务逻辑progress / isOverrun - Budget 模型封装 usage / isOverrun 阈值算法 - 封装 ProgressBar / BudgetCard 通用组件 - 实现 BudgetView 页面与超支告警条 - 修复 Prop height 与基类方法冲突改用 barHeight12.2 变更日志## [v0.1.7] - 2026-07-27 ### Added - viewmodel/BudgetViewModel.ets预算进度与超支状态计算 - components/chart/ProgressBar.etsbarHeight 规避命名冲突 - components/card/BudgetCard.ets预算卡 进度条 ### Changed - BudgetView 集成 BudgetCard 与超支告警条 - DateUtil.monthStart/End 传入 Date.now() 参数附录运行效果截图总结本文完整介绍了预算提醒与预算进度的全流程涵盖Budget模型阈值算法、BudgetViewModel业务逻辑、ProgressBar与BudgetCard组件封装、BudgetView页面集成以及关键的 ArkTSProp属性命名冲突陷阱。通过本篇你可以设计usage/isOverrun单一数据源的阈值算法封装BudgetViewModel聚合双 Repository 数据使用ProgressBarbarHeight与BudgetCard组件化展示预算规避Prop width/height与CustomComponent基类方法的命名冲突实现超支状态驱动的红色告警条下一篇预告[Canvas 绘制饼图]—— 使用CircleChart组件展示分类支出占比。如果这篇文章对你有帮助欢迎点赞、收藏、关注你的支持是我持续创作的动力在评论区告诉我你最想了解的鸿蒙开发话题我会优先安排下一篇内容也可以为下期主题投票。相关资源本篇源码GitHub Tag v0.1.7HarmonyOS NEXT 开发者文档developer-docArkUI 通用组件arkui-tsArkUI 状态管理state-managementArkUI Canvas 组件canvasArkUI Animation 动画animation鸿蒙数据存储data-storage