账单编辑与删除

本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第 14 篇,对应 Git Tag v0.1.4。承接第 13 篇的 PersistenceV2,本篇开发 EditBillView 编辑页面,支持长按列表项弹删除菜单、滑动删除手势、二次确认对话框。

前言

账单的 编辑与删除 是记账应用的基础功能。删除是不可逆的敏感操作,必须有 二次确认 + 滑动手势 + 长按菜单 多重入口,避免误触。本章开发 EditBillView 完整编辑页,并封装 ConfirmDialog 删除确认对话框。

本文将带你:

  1. 开发 EditBillView 完整编辑页面(复用 AddBillView 组件)
  2. 实现长按列表项弹删除菜单
  3. 实现左滑删除手势 + 红色删除按钮揭示
  4. 封装 ConfirmDialog 二次确认对话框
  5. 集成 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.delete

7.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 --mode module -p product=default

8.2 功能验证

  1. 点击首页账单项跳转 EditBillView,预填数据
  2. 修改金额、分类、备注后保存,返回首页列表刷新
  3. 顶栏点击删除按钮,弹 ConfirmDialog,确认后删除并返回
  4. 长按列表项,弹长按菜单含"编辑/删除"
  5. 左滑列表项 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 提交

git add .
git commit -m "feat(bill): 开发账单编辑与删除功能

- 新增 EditBillView 完整编辑页面
- 新增 EditBillViewModel 处理加载/更新/删除
- 封装 ConfirmDialog 二次确认对话框
- 首页列表项支持长按弹菜单(编辑/删除)
- 首页列表项支持左滑 80dp 触发删除
- 集成 BillRepository.update 与 delete"

总结

本文完整介绍了 账单编辑与删除,涵盖 EditBillView、EditBillViewModel、ConfirmDialog、长按菜单、滑动删除。通过本篇你可以:

  • 复用 AddBillView 组件快速搭编辑页
  • 用 ConfirmDialog 实现删除二次确认
  • 用 LongPressGesture + PanGesture 添加列表交互
  • 通过路由参数传递 billId 加载编辑数据
  • 从子页面返回时刷新首页列表

下一篇预告:《账单搜索与筛选》将开发 SearchView 搜索页,支持关键字、分类、日期范围、金额范围多维筛选。


如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源

Logo

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

更多推荐