预算中心开发ProgressBar CustomDialog ArkTS 编译陷阱实战本文是《HarmonyOS NEXT 企业级开发实战30篇打造智能记账APP》系列的第16篇对应 Git Tagv0.1.6。承接第 15 篇的搜索筛选本篇开发 BudgetView 预算中心支持月度预算设置、已用进度、剩余金额、超支提醒。同时深入讲解 ArkTS 中Prop不支持函数类型的arkts-no-props-function错误以及属性名与基类方法冲突的编译问题。前言预算中心是记账应用的财务管家——用户设置月度预算后应用自动统计本月支出实时展示已用/剩余/进度条超支时红警提醒。本章封装 BudgetCard、ProgressBar 两个核心组件集入 BudgetRepository 完成数据闭环并通过CustomDialog实现预算设置对话框。本文将带你开发 BudgetView 完整页面预算卡 进度条 超支提醒 收支概览封装 BudgetCard 展示预算详情onTap回调替代BuilderParam封装 ProgressBar 进度条组件barHeight替代height避免基类冲突封装 InputDialogCustomDialog自定义对话框替代Builder方案深入理解 ArkTS 中arkts-no-props-function与属性名冲突的编译陷阱企业级核心原则预算必须实时同步、视觉清晰、超支预警。参考 ArkUI ProgressBar 了解官方约定。一、Budget 数据模型1.1 完整源码Budget模型封装了月度预算的核心数据与计算逻辑// 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); } static fromObject(data: ESObject): Budget { const b new Budget(); if (data null || data undefined) { return b; } if (data.id ! undefined) b.id String(data.id); if (data.month ! undefined) b.month String(data.month); if (data.budget ! undefined) b.budget Number(data.budget); if (data.used ! undefined) b.used Number(data.used); if (data.remain ! undefined) b.remain Number(data.remain); return b; } }1.2 核心方法说明方法返回类型说明refresh(used)void刷新已用金额并计算剩余isOverrun()boolean是否超支used budgetusage()number使用率 0~1上限 1超支不超 100%fromObject(data)Budget从 ESObject 反序列化JSON 恢复设计要点usage()返回Math.min(used / budget, 1)超支时进度条封顶 100% 而非溢出保证 UI 不会变形。二、BudgetViewModel 业务层2.1 完整源码BudgetViewModel负责加载月度预算、统计收支、设置预算// 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当前月份如2026-07budgetBudget | null本月预算实体未设置时为 nulltotalExpensenumber本月支出分totalIncomenumber本月收入分progressnumber使用率 0~1isOverrunboolean是否超支isLoadingboolean加载态2.3 月度统计流程BudgetViewModel.loadData() ↓ 获取当前月份 → DateUtil.formatDate(Date.now()).substring(0, 7) ↓ 查询本月预算 → budgetRepo.findCurrentMonth() ↓ 查询本月账单 → billRepo.findByDateRange(monthStart, monthEnd) ↓ 统计支出/收入 → filter reduce ↓ 刷新预算 → budget.refresh(totalExpense) ↓ 计算进度与超支 → usage() / isOverrun()金额单位所有金额以分为单位存储整数通过MoneyUtil.format()转换为元显示避免浮点精度问题。三、ProgressBar 通用组件3.1 完整源码ProgressBar使用 Stack 叠放轨道与进度条支持自定义颜色和高度// 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) } }3.2 设计要点用Stack叠放轨道底层与进度条上层实现进度覆盖效果进度条宽度用百分比${Math.min(this.progress, 1) * 100}%超支时封顶 100%添加300ms EaseInOut 动画进度变化平滑过渡barHeight同时控制轨道、进度条和容器高度保持视觉一致borderRadius设为barHeight / 2自动适配圆角注意属性名使用barHeight而非height原因详见第四章的编译陷阱分析。四、ArkTS 编译陷阱属性名冲突与函数类型4.1 height 属性名冲突最初 ProgressBar 组件使用Prop height: number 8作为属性名但编译时报错// ❌ 错误写法height 与基类方法冲突 Component export struct ProgressBar { Prop progress: number 0; Prop color: string AppColors.Budget; Prop trackColor: string AppColors.Background; Prop height: number 8; // 编译报错 build() { Stack() { Column().width(100%).height(this.height)... } .width(100%).height(this.height) } }编译器报错信息Property height in type ProgressBar is not assignable to the same property in base type CustomComponent. Type number is not assignable to type ((value: Length) void) | undefined.根因分析ArkTS 中所有Component结构体都隐式继承自CustomComponent基类。CustomComponent已经定义了height方法用于设置组件高度的链式调用。当你在子组件中用Prop height: number声明同名属性时类型number与基类方法((value: Length) void) | undefined不兼容导致编译失败。4.2 Prop 不支持函数类型最初 BudgetCard 组件使用BuilderParam onClick接收点击回调但尝试用Prop传递函数时也遇到了编译错误// ❌ 错误写法Prop 不能用于函数类型 Component export struct BudgetCard { Prop budget: Budget new Budget(, , 0); Prop totalExpense: number 0; Prop onClick: () void () {}; // 编译报错arkts-no-props-function }编译器报错信息arkts-no-props-function根因分析ArkTS 的Prop装饰器仅支持值类型string/number/boolean/ 对象等不支持函数类型。这是因为Prop会在父子组件之间进行深拷贝而函数无法被序列化拷贝。ArkTS 编译器通过arkts-no-props-function规则直接拦截这种用法。4.3 解决方案对比问题错误写法正确写法原因height 冲突Prop height: numberProp barHeight: number避免与CustomComponent.height()方法同名函数回调Prop onClick: () voidonTap: () void () {}普通属性可接收函数Prop不行文本对齐TextAlign.RightTextAlign.EndRight已废弃用End替代4.4 TextAlign.Right 弃用问题// ❌ 旧写法TextAlign.Right 已废弃 Text(MoneyUtil.formatWithComma(this.budget.budget)) .textAlign(TextAlign.Right) // ✅ 新写法使用 TextAlign.End Text(MoneyUtil.formatWithComma(this.budget.budget)) .textAlign(TextAlign.End)原因鸿蒙 ArkUI 中TextAlign.Right在某些版本中被标记为废弃推荐使用TextAlign.End。两者效果相同右对齐但End在 RTL从右到左布局中能正确适配是更规范的写法。总结ArkTS 编译器比 TypeScript 更严格属性名不能与基类方法冲突Prop不能用于函数类型。遇到编译错误时重命名属性和改用普通属性是最常见的解决方案。五、BudgetCard 通用组件5.1 完整源码BudgetCard展示预算金额、进度条、已用/剩余金额超支时进度条变红// 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(); }) } }5.2 设计要点onTap: () void () {}作为普通属性接收点击回调默认空函数避免未传参时报错进度条颜色根据isOverrun()动态切换正常蓝色、超支红色剩余金额用Math.max(this.budget.remain, 0)确保不显示负数已用金额在超支时也变红形成视觉一致性textAlign(TextAlign.End)右对齐预算金额与左侧标签形成对比整个卡片可点击onClick触发onTap回调打开设置对话框5.3 超支视觉策略状态进度条颜色已用金额颜色说明正常蓝色#007AFF深色#1C1C1E预算充足超支红色#FF3B30红色#FF3B30进度封顶 100%红色告警视觉一致性超支时进度条和已用金额同时变红让用户一眼感知到预算超支状态无需阅读具体数字。六、InputDialog 自定义对话框6.1 完整源码InputDialog使用CustomDialog装饰器封装通用的文本输入对话框// components/dialog/InputDialog.ets import { AppColors } from ../../theme/Colors; import { AppFontSize } from ../../theme/Typography; import { AppSpace } from ../../theme/Spacing; CustomDialog export struct InputDialog { controller: CustomDialogController; title: string ; placeholder: string ; confirmText: string 确认; cancelText: string 取消; onConfirm: (value: string) void () {}; onCancel: () void () {}; State inputValue: string ; build() { Column() { Text(this.title) .fontSize(AppFontSize.LG) .fontColor(AppColors.PrimaryText) .fontWeight(FontWeight.Bold) .margin({ bottom: AppSpace.MD }) TextInput({ placeholder: this.placeholder, text: $$this.inputValue }) .width(100%) .height(44) .backgroundColor(AppColors.Background) .borderRadius(AppSpace.SM) Row() { Button(this.cancelText) .backgroundColor(AppColors.Background) .fontColor(AppColors.SecondaryText) .onClick(() { this.controller.close(); this.onCancel(); }) Button(this.confirmText) .backgroundColor(AppColors.Budget) .fontColor(#FFFFFF) .onClick(() { this.controller.close(); this.onConfirm(this.inputValue); }) } .width(100%) .margin({ top: AppSpace.LG }) .justifyContent(FlexAlign.SpaceBetween) } .width(100%) .padding(AppSpace.LG) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) } }6.2 CustomDialog 机制说明CustomDialog vs Builder 对比旧版预算设置使用Builder buildSetDialog()在页面内构建弹窗代码耦合度高、复用性差。改用CustomDialog后对话框成为独立组件可被任意页面复用通过CustomDialogController统一管理生命周期。6.3 对话框属性说明属性类型说明controllerCustomDialogController框架自动注入用于关闭对话框titlestring对话框标题placeholderstring输入框占位文本onConfirm(value: string) void确认回调接收输入值onCancel() void取消回调inputValuestringState双向绑定的输入值关键技术TextInput使用$$this.inputValue双向绑定语法$$用户输入时自动更新inputValue状态点击确认时通过onConfirm(this.inputValue)传出。七、BudgetView 页面实现7.1 完整源码BudgetView是预算中心的主页面展示预算卡片、收支概览、超支提醒并通过CustomDialogController调用InputDialog// 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 CustomDialogController 调用解析showBudgetInput()方法是整个对话框交互的核心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(); }7.3 页面状态管理状态类型触发时机UI 变化isLoadingbooleanaboutToAppear/loadData加载中不渲染内容viewModel.budgetBudget | nullloadData完成null 显示引导非 null 显示卡片viewModel.isOverrunbooleanloadData完成true 显示超支红色提醒条关键改进旧版使用State showSetDialog: booleanBuilder buildSetDialog()手动管理弹窗显隐需要自己实现遮罩层和关闭逻辑。新版使用CustomDialogController框架自动管理遮罩、动画和关闭代码更简洁可靠。八、最佳实践8.1 进度条超支红色// 超支时进度条 100% 红色视觉强警 progress Math.min(this.budget.usage(), 1); // 上限 1 color this.budget.isOverrun() ? AppColors.Expense : AppColors.Budget;8.2 预算与账单联动// 每次新增/删除账单后预算页需重新统计 // 解决预算页 aboutToAppear 重新 loadData aboutToAppear(): void { this.loadData(); }8.3 颜色策略使用率颜色语义色值0%-60%蓝色正常#007AFF60%-80%蓝色注意#007AFF80%-100%蓝色警戒#007AFF100%红色超支#FF3B30当前实现中进度条颜色仅在超支时切换为红色正常状态下统一使用蓝色。未来可扩展为多级颜色策略60% 黄色、80% 橙色。8.4 预算数据可视化预算数据可以通过图表组件直观展示图表类型展示内容交互方式饼图各类支出占比点击查看详情柱状图每日支出 vs 日均预算悬停显示数值折线图月度支出趋势拖动查看历史可视化展示让预算数据更加直观帮助用户快速了解财务状况。详见后续 Canvas 绘图系列文章。九、运行验证9.1 编译检查hvigorw assembleHap--modemodule-pproductdefault9.2 功能验证验证项预期首次进入预算中心显示空状态引导含设置预算按钮设置 5000 元预算进度条展示已用占比收支概览显示超支后进度条红色 超支提醒条修改预算点击卡片弹出 InputDialog输入新值保存账单变动后返回aboutToAppear 重新 loadData数据刷新9.3 预算设置流程点击设置预算按钮或点击已有预算卡片showBudgetInput()创建CustomDialogController并打开InputDialog在输入框中输入金额元点击确认onConfirm回调将元转为分Math.round(amount * 100)调用viewModel.setBudget(cents)保存到 Repository保存成功后调用loadData()刷新页面9.4 预算修改流程点击已有预算卡片触发onTap回调弹出InputDialog输入新金额点击确认setBudget会覆盖当前月份预算BudgetRepository.setMonthBudget查找已有记录并更新十、常见问题10.1 CustomDialog 不弹出// 错误CustomDialogController 未调用 open() let controller new CustomDialogController({ builder: InputDialog({...}) }); // 忘记调用 open() // 解决创建后必须调用 open() controller.open();10.2 属性名 height 编译报错// 错误Prop height 与 CustomComponent 基类方法冲突 Prop height: number 8; // 解决改用 barHeight Prop barHeight: number 8;10.3 Prop 函数类型报错// 错误arkts-no-props-function Prop onClick: () void () {}; // 解决改用普通属性不加 Prop onTap: () void () {};十一、Git 提交11.1 Commit Messagegitadd.gitcommit-mfeat(budget): 开发预算中心与 BudgetCard - 新增 BudgetView 完整页面含进度条超支提醒收支概览 - 封装 BudgetCard 通用预算卡组件onTap 回调 - 封装 ProgressBar 通用进度条组件barHeight 避免基类冲突 - 封装 InputDialog CustomDialog 自定义输入对话框 - 新增 BudgetViewModel 处理月度统计 - 集入 BudgetRepository 与 BillRepository - 修复 arkts-no-props-function 与 height 属性名冲突11.2 CHANGELOG## [v0.1.6] - 2026-07-27 ### Added - components/tabs/BudgetView.ets预算中心页面 - components/card/BudgetCard.ets预算卡片组件 - components/chart/ProgressBar.ets进度条组件 - components/dialog/InputDialog.ets自定义输入对话框 - viewmodel/BudgetViewModel.ets预算业务逻辑 - model/Budget.ets预算数据模型 ### Fixed - ProgressBar: height → barHeight避免 CustomComponent 基类冲突 - BudgetCard: BuilderParam onClick → onTap 普通属性arkts-no-props-function - TextAlign.Right → TextAlign.End使用规范 API附录运行效果截图总结本文完整介绍了预算中心开发涵盖 Budget 数据模型、BudgetViewModel、ProgressBar、BudgetCard、InputDialogCustomDialog、BudgetView 完整页面。通过本篇你可以封装 ProgressBar 进度条组件使用barHeight避免与基类height方法冲突封装 BudgetCard 预算卡组件使用普通属性onTap替代Prop函数类型封装 InputDialogCustomDialog自定义对话框替代Builder手动弹窗方案通过CustomDialogController统一管理对话框生命周期理解 ArkTS 中arkts-no-props-function和属性名冲突的编译陷阱及解决方案联动 BillRepository 统计月度支出处理超支视觉预警下一篇预告[《预算提醒与预算进度》] 将开发预算阈值提醒80% 黄警、100% 红警、超支弹通知封装 NotificationUtil 推送本地通知。如果这篇文章对你有帮助欢迎点赞、收藏、关注你的支持是我持续创作的动力在评论区告诉我你在 ArkTS 组件开发中还遇到过哪些编译陷阱我会针对性地分享更多实战经验。相关资源本篇源码GitHub Tag v0.1.6ArkUI ProgressBarprogressArkUI CustomDialogcustom-dialogArkUI DialogdialogArkUI Prop 装饰器prop-decoratorArkUI Stack 布局stackArkUI 动画animationArkTS 编译规则arkts-rules鸿蒙通知服务notification-service