HarmonyOS NEXT 企业级记账APP:账单编辑与删除
账单编辑与删除
本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第14篇,对应 Git Tagv0.1.4。承接第 13 篇的 PersistenceV2,本篇开发 EditBillView 编辑页面,支持长按列表项弹删除菜单、滑动删除手势、二次确认对话框。
前言
账单的编辑与删除是记账应用的基础功能。删除是不可逆的敏感操作,必须有二次确认 + 滑动手势 + 长按菜单多重入口,避免误触。本章开发 EditBillView 完整编辑页,并封装 ConfirmDialog 删除确认对话框。
本文将带你:
- 开发 EditBillView 完整编辑页面(复用 AddBillView 组件)
- 实现长按列表项弹删除菜单
- 实现左滑删除手势 + 红色删除按钮揭示
- 封装 ConfirmDialog 二次确认对话框
- 集成 BillRepository 完成删除与更新
企业级核心原则:删除操作必须二次确认、可撤销提示、手势入口。参考 ArkUI 手势文档 了解官方约定。
一、EditBillView 页面规划
1.1 页面结构树
EditBillView(编辑账单) ├─ TitleBar(顶栏:返回 + 标题"编辑账单" + 删除按钮) ├─ TypeSwitch(收入/支出切换) ├─ MoneyInput(金额输入) ├─ CategorySelector(分类选择) ├─ DateSelector + TimeSelector(日期时间) ├─ RemarkInput(备注) └─ ActionBar(取消 + 保存)1.2 与 AddBillView 的区别
| 项 | AddBillView | EditBillView |
|---|---|---|
| 入口 | FAB 新增 | 列表项点击 |
| 标题 | “新增账单” | “编辑账单” |
| 顶栏 | 仅返回 | 返回 + 删除 |
| 初始数据 | 默认空 | 路由参数传入 billId 加载 |
| 保存 | create | update |
| 删除 | 无 | 有(红色按钮 + 二次确认) |
二、EditBillViewModel 业务层
// viewmodel/EditBillViewModel.ets import { Bill } from '../model/Bill'; import { BillType } from '../constants/BillType'; import { BillRepository } from '../repository/BillRepository'; import { CategoryRepository } from '../repository/CategoryRepository'; import { Category } from '../model/Category'; import { MoneyUtil } from '../utils/MoneyUtil'; import { ToastUtil } from '../utils/ToastUtil'; import { UUIDUtil } from '../utils/UUIDUtil'; export class EditBillViewModel { billId: string = ''; currentType: BillType = BillType.EXPENSE; money: string = ''; selectedCategory: Category | null = null; date: number = Date.now(); remark: string = ''; availableCategories: Category[] = []; private billRepo = BillRepository.getInstance(); private categoryRepo = CategoryRepository.getInstance(); private originalBill: Bill | null = null; /** 加载账单数据 */ async loadBill(billId: string): Promise<void> { this.billId = billId; const bill = await this.billRepo.findById(billId); if (!bill) { ToastUtil.show('账单不存在'); return; } this.originalBill = bill; this.currentType = bill.type; this.money = MoneyUtil.format(bill.money); this.date = bill.date; this.remark = bill.remark; await this.loadCategories(); // 反查分类 this.selectedCategory = this.availableCategories.find(c => c.id === bill.categoryId) ?? null; } async loadCategories(): Promise<void> { this.availableCategories = await this.categoryRepo.findByType(this.currentType); } async switchType(type: BillType): Promise<void> { this.currentType = type; this.selectedCategory = null; await this.loadCategories(); } canSave(): boolean { if (this.money.length === 0) return false; const cents = MoneyUtil.parseToCents(this.money); if (cents <= 0) return false; if (!this.selectedCategory) return false; return true; } /** 更新账单 */ async save(): Promise<boolean> { if (!this.canSave() || !this.originalBill) return false; const cents = MoneyUtil.parseToCents(this.money); this.originalBill.money = cents; this.originalBill.type = this.currentType; this.originalBill.categoryId = this.selectedCategory!.id; this.originalBill.remark = this.remark; this.originalBill.date = this.date; await this.billRepo.update(this.originalBill); ToastUtil.show('账单已更新'); return true; } /** 删除账单 */ async delete(): Promise<boolean> { if (!this.billId) return false; const ok = await this.billRepo.delete(this.billId); if (ok) ToastUtil.show('账单已删除'); return ok; } }三、EditBillView 页面实现
// pages/EditBillView.ets import { EditBillViewModel } from '../viewmodel/EditBillViewModel'; import { TypeSwitch } from '../components/form/TypeSwitch'; import { MoneyInput } from '../components/form/MoneyInput'; import { CategorySelector } from '../components/selector/CategorySelector'; import { DateSelector } from '../components/selector/DateSelector'; import { TimeSelector } from '../components/selector/TimeSelector'; import { AppButton } from '../components/form/AppButton'; import { ConfirmDialog } from '../components/dialog/ConfirmDialog'; import { AppColors } from '../theme/Colors'; import { AppFontSize } from '../theme/Typography'; import { AppSpace } from '../../theme/Spacing'; import { BillType } from '../constants/BillType'; import { router } from '@kit.ArkUI'; @Entry @Component struct EditBillView { @State viewModel: EditBillViewModel = new EditBillViewModel(); @State isLoading: boolean = true; @State showDeleteConfirm: boolean = false; aboutToAppear() { const params = router.getParams() as Record<string, string>; this.loadData(params['id']); } private async loadData(billId: string): Promise<void> { this.isLoading = true; await this.viewModel.loadBill(billId); this.isLoading = false; } build() { Stack() { Column() { // 顶栏:返回 + 标题 + 删除 Row() { Image($r('app.media.icon_back')) .width(24).height(24).fillColor(AppColors.PrimaryText) .onClick(() => { router.back(); }) Text('编辑账单') .fontSize(AppFontSize.XL).fontWeight(FontWeight.Bold) .layoutWeight(1).textAlign(TextAlign.Center) Image($r('app.media.icon_delete')) .width(24).height(24).fillColor(AppColors.Expense) .onClick(() => { this.showDeleteConfirm = true; }) } .width('100%').height(56).alignItems(HorizontalAlign.Center) // ... 同 AddBillView 的 TypeSwitch / MoneyInput / CategorySelector / DateSelector / TimeSelector / Remark ... // 底栏:取消 + 保存 Row({ space: AppSpace.MD }) { AppButton({ label: '取消', color: AppColors.SecondaryText, onClick: () => { router.back(); } }) AppButton({ label: '保存', color: this.viewModel.currentType === BillType.INCOME ? AppColors.Income : AppColors.Expense, enabled: this.viewModel.canSave(), onClick: async () => { const ok = await this.viewModel.save(); if (ok) router.back(); } }) } .margin({ top: AppSpace.XL }) } .height('100%') .padding({ left: AppSpace.XL, right: AppSpace.XL }) .backgroundColor(AppColors.Background) // 删除确认对话框 if (this.showDeleteConfirm) { ConfirmDialog({ title: '确认删除', message: '删除后无法恢复,是否确认删除该账单?', confirmText: '删除', confirmColor: AppColors.Expense, onConfirm: async () => { const ok = await this.viewModel.delete(); this.showDeleteConfirm = false; if (ok) router.back(); }, onCancel: () => { this.showDeleteConfirm = false; } }) } } } }四、ConfirmDialog 二次确认对话框
// components/dialog/ConfirmDialog.ets import { AppColors } from '../../theme/Colors'; import { AppFontSize, AppFontWeight } from '../../theme/Typography'; import { AppSpace } from '../../theme/Spacing'; @Component export struct ConfirmDialog { @Prop title: string = '确认'; @Prop message: string = ''; @Prop confirmText: string = '确认'; @Prop cancelText: string = '取消'; @Prop confirmColor: string = AppColors.Budget; @BuilderParam onConfirm: () => void; @BuilderParam onCancel: () => void; build() { Stack() { // 蒙层 Column() .width('100%').height('100%') .backgroundColor('#80000000') .onClick(() => { this.onCancel(); }) // Dialog 卡片 Column() { Text(this.title) .fontSize(AppFontSize.LG).fontWeight(AppFontWeight.Bold) .margin({ bottom: AppSpace.MD }) Text(this.message) .fontSize(AppFontSize.MD).fontColor(AppColors.SecondaryText) .textAlign(TextAlign.Center) .margin({ bottom: AppSpace.LG }) Row({ space: AppSpace.MD }) { Text(this.cancelText) .layoutWeight(1) .textAlign(TextAlign.Center) .padding({ top: 12, bottom: 12 }) .backgroundColor(AppColors.Background) .borderRadius(24) .onClick(() => { this.onCancel(); }) Text(this.confirmText) .layoutWeight(1) .textAlign(TextAlign.Center) .fontColor('#FFFFFF') .padding({ top: 12, bottom: 12 }) .backgroundColor(this.confirmColor) .borderRadius(24) .onClick(() => { this.onConfirm(); }) } .width('100%') } .width('80%') .padding(AppSpace.LG) .backgroundColor(AppColors.CardBackground) .borderRadius(16) } .width('100%').height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } }五、首页列表项长按与滑动删除
5.1 长按弹菜单
// pages/HomeView.ets(BillCard 部分升级) import { ConfirmDialog } from '../components/dialog/ConfirmDialog'; @State longPressedBillId: string = ''; build() { // ... ForEach(this.viewModel.recentBills, (bill: Bill) => { ListItem() { BillCard({ billId: bill.id, money: bill.money, type: bill.type, categoryName: bill.categoryName, categoryIcon: bill.categoryIcon, remark: bill.remark, date: DateUtil.formatTime(bill.date), onClick: () => { this.viewModel.goBillDetail(bill.id); }, onLongPress: () => { this.longPressedBillId = bill.id; } }) } .gesture( PanGesture({ distance: 80, direction: PanDirection.Horizontal }) .onActionStart(() => { this.swipeBillId = bill.id; }) .onActionUpdate((e: GestureEvent) => { this.swipeOffset = e.offset.x; }) .onActionEnd(() => { this.handleSwipeEnd(bill.id); }) ) }, (bill: Bill) => bill.id) // 长按菜单蒙层 if (this.longPressedBillId.length > 0) { this.buildLongPressMenu() } } @Builder buildLongPressMenu() { Stack() { Column().width('100%').height('100%').backgroundColor('#80000000') .onClick(() => { this.longPressedBillId = ''; }) Column() { Text('编辑').padding(16).onClick(() => { this.viewModel.goEditBill(this.longPressedBillId); this.longPressedBillId = ''; }) Divider().color(AppColors.Separator) Text('删除').padding(16).fontColor(AppColors.Expense).onClick(() => { this.deleteBillId = this.longPressedBillId; this.showDeleteConfirm = true; this.longPressedBillId = ''; }) } .backgroundColor(AppColors.CardBackground).borderRadius(12).padding({ left: 16, right: 16 }) } }5.2 左滑删除手势
@State swipeBillId: string = ''; @State swipeOffset: number = 0; private handleSwipeEnd(billId: string): void { // 滑动距离 < -80 触发删除确认 if (this.swipeOffset < -80) { this.deleteBillId = billId; this.showDeleteConfirm = true; } this.swipeOffset = 0; this.swipeBillId = ''; } // ListItem 内套用 Stack: Stack() { // 底层红色删除按钮 Row() { Image($r('app.media.icon_delete')).width(20).height(20).fillColor('#FFFFFF') } .width(80).height('100%').backgroundColor(AppColors.Expense) .justifyContent(FlexAlign.Center) // 上层 BillCard,偏移随滑动 BillCard({ ... }) .offset({ x: this.swipeBillId === bill.id ? this.swipeOffset : 0 }) }六、路由更新与返回刷新
6.1 路由配置
// main_pages.json { "src": [ "pages/MainView", "pages/HomeView", "pages/StatisticsView", "pages/BudgetView", "pages/ProfileView", "pages/AddBillView", "pages/EditBillView", "pages/BillDetailView" // 新增 ] }6.2 返回首页刷新
// pages/HomeView.ets onPageShow() { // 从编辑/新增返回时重新加载列表 this.loadData(); } private async loadData(): Promise<void> { this.isLoading = true; await this.viewModel.loadData(); this.isLoading = false; }关键技术:
onPageShow在页面显示时触发,从子页面返回会重新拉数据保持列表新鲜。
七、最佳实践
7.1 删除二次确认
// 删除是不可逆操作,必须二次确认 if (!this.showDeleteConfirm) { // 首次点击不直接删,先弹 ConfirmDialog this.showDeleteConfirm = true; } // 用户在 ConfirmDialog 点确认后才真正调用 Repository.delete7.3 手势类型对比
| 手势 | 触发距离 | 时长 | 用途 |
|---|---|---|---|
| TapGesture | 无 | 短按 | 点击 |
| LongPressGesture | 无 | 500ms | 长按菜单 |
| PanGesture | 80dp | 无 | 滑动删除 |
合理使用不同手势类型可以显著提升用户操作效率。
7.1 删除操作对比
| 操作 | 触发方式 | 确认机制 | 反馈 |
|---|---|---|---|
| 顶栏删除按钮 | 点击图标 | ConfirmDialog 二次确认 | Toast 提示 |
| 长按菜单删除 | LongPress 500ms | 弹菜单后选删除 | ConfirmDialog |
| 左滑删除 | PanGesture 80dp | 松开即触发 ConfirmDialog | 红色按钮揭示 |
三种删除入口覆盖不同使用习惯,但均经过二次确认保护。
7.2 手势与点击冲突
// ArkUI 自动处理:短按触发 onClick,长按触发 LongPress,滑动触发 Pan // 距离阈值 distance: 80 避免误触 .gesture(PanGesture({ distance: 80, direction: PanDirection.Horizontal }))7.3 编辑页复用新增页组件
AddBillView 与 EditBillView 共用: - TypeSwitch - MoneyInput - CategorySelector - DateSelector / TimeSelector - AppButton - ConfirmDialog(仅 Edit 用) 差异仅:顶栏删除按钮 + 标题文字 + 初始数据加载八、运行验证
8.1 编译检查
hvigorw assembleHap--modemodule-pproduct=default8.2 功能验证
- 点击首页账单项跳转 EditBillView,预填数据
- 修改金额、分类、备注后保存,返回首页列表刷新
- 顶栏点击删除按钮,弹 ConfirmDialog,确认后删除并返回
- 长按列表项,弹长按菜单含"编辑/删除"
- 左滑列表项 80dp,弹删除确认对话框
九、常见问题
9.1 路由参数丢失
// EditBillView 通过 router.getParams() 获取 billId const params = router.getParams() as Record<string, string>; const billId = params['id']; // 必须与跳转时传入的 key 一致9.2 滑动距离判断不准
// PanGesture distance 是触发阈值,offset 是实际偏移 // 负值代表左滑,需根据 direction 配置 direction: PanDirection.Horizontal // 仅水平滑动9.3 ConfirmDialog 蒙层穿透
// 蒙层必须 onClick 关闭,否则点击空白无反应 Column().backgroundColor('#80000000').onClick(() => { this.onCancel(); })十、Git 提交
gitadd.gitcommit-m"feat(bill): 开发账单编辑与删除功能 - 新增 EditBillView 完整编辑页面 - 新增 EditBillViewModel 处理加载/更新/删除 - 封装 ConfirmDialog 二次确认对话框 - 首页列表项支持长按弹菜单(编辑/删除) - 首页列表项支持左滑 80dp 触发删除 - 集成 BillRepository.update 与 delete"总结
本文完整介绍了账单编辑与删除,涵盖 EditBillView、EditBillViewModel、ConfirmDialog、长按菜单、滑动删除。通过本篇你可以:
- 复用 AddBillView 组件快速搭编辑页
- 用 ConfirmDialog 实现删除二次确认
- 用 LongPressGesture + PanGesture 添加列表交互
- 通过路由参数传递 billId 加载编辑数据
- 从子页面返回时刷新首页列表
下一篇预告:《账单搜索与筛选》将开发 SearchView 搜索页,支持关键字、分类、日期范围、金额范围多维筛选。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源
- 本篇源码:GitHub Tag v0.1.4
- ArkUI 手势文档:gesture
- PanGesture API:pan-gesture
- LongPressGesture:long-press-gesture
- ArkUI Dialog 系统:dialog
- 鸿蒙手势最佳实践:gesture-best-practice
- ArkUI 路由跳转:router