30分钟上手 HarmonyOS NEXT:手把手教你做记账 App

零基础也能学会!从项目创建到完整运行,保姆级教程带你打造第一个鸿蒙原生应用

前言:为什么选择记账 App?

Hello,各位开发者!今天带大家入门 HarmonyOS NEXT 开发,我们选择一个最实用的项目——个人财务记账应用

为什么是记账 App?

  • ✅ 功能完整,覆盖增删改查
  • ✅ 包含图表,学习 Canvas 绑制
  • ✅ 数据持久化,掌握本地存储
  • ✅ 界面美观,练手 UI 设计

SDK:HarmonyOS NEXT API 23
开发工具:DevEco Studio

准备好了吗?Let’s go! 🚀


一、先看最终效果

完成后的应用长这样:

首页:月度收支一目了然

  • 本月结余:大字显示,收入绿色、支出红色
  • 环形图:各分类占比清清楚楚
  • 近期交易:最近5笔快速查看

记账页:一键快速记录

  • 切换收入/支出
  • 选择分类(餐饮、交通、购物…)
  • 选日期、写备注
  • 点击保存,搞定!

账单列表:历史记录全掌握

  • 按日期分组
  • 左滑删除
  • 点击编辑

统计页:数据可视化

  • 每日收支柱状图
  • 分类占比饼图
  • 切换月份查看

分类管理:自定义你的分类

  • 添加新分类
  • 选择 Emoji 图标
  • 删除不需要的

二、创建项目

2.1 新建项目

  1. 打开 DevEco Studio
  2. File → New → Create Project
  3. 选择 “Application” → “Empty Ability”
  4. 填写项目信息:
    • Project name: MyApplication
    • Bundle name: com.example.myapplication
    • API: 23(HarmonyOS NEXT)
  5. 点击 Finish,等待项目创建完成

2.2 项目结构

创建后,项目结构如下:

MyApplication/
├── AppScope/
│   └── app.json5          # 全局配置
├── entry/
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/
│   │   │   │   └── EntryAbility.ets
│   │   │   └── pages/
│   │   │       └── Index.ets
│   │   └── module.json5
│   └── build-profile.json5
└── build-profile.json5

三、设计数据结构

3.1 创建模型文件

右键 ets 文件夹 → New → Directory,命名为 models

再右键 models → New → File,命名为 FinanceModels.ets

3.2 定义数据模型

// models/FinanceModels.ets

// 交易类型
export enum TransactionType {
  EXPENSE = 0,  // 支出
  INCOME = 1    // 收入
}

// 分类
export interface Category {
  id: number;
  name: string;
  icon: string;
  type: TransactionType;
}

// 交易记录
export interface Transaction {
  id: number;
  type: TransactionType;
  amount: number;
  categoryId: number;
  note: string;
  date: string;  // 格式: YYYY-MM-DD
}

// 工具函数
export function makeCategory(id: number, name: string, icon: string, type: TransactionType): Category {
  return { id, name, icon, type };
}

export function makeTransaction(type: TransactionType, amount: number, categoryId: number, note: string, date: string): Transaction {
  let nextId = 1;
  const id = nextId;
  nextId++;
  return { id, type, amount, categoryId, note, date };
}

// 默认分类
export const DEFAULT_EXPENSE_CATEGORIES: Category[] = [
  makeCategory(1, '餐饮', '🍜', TransactionType.EXPENSE),
  makeCategory(2, '交通', '🚌', TransactionType.EXPENSE),
  makeCategory(3, '购物', '🛒', TransactionType.EXPENSE),
  makeCategory(4, '娱乐', '🎮', TransactionType.EXPENSE),
  makeCategory(5, '住房', '🏠', TransactionType.EXPENSE),
  makeCategory(6, '医疗', '💊', TransactionType.EXPENSE),
  makeCategory(7, '教育', '📚', TransactionType.EXPENSE),
  makeCategory(8, '其他', '📦', TransactionType.EXPENSE),
];

export const DEFAULT_INCOME_CATEGORIES: Category[] = [
  makeCategory(9, '工资', '💼', TransactionType.INCOME),
  makeCategory(10, '兼职', '💻', TransactionType.INCOME),
  makeCategory(11, '红包', '🧧', TransactionType.INCOME),
  makeCategory(12, '理财', '📈', TransactionType.INCOME),
  makeCategory(13, '其他', '💰', TransactionType.INCOME),
];

// 图表颜色
export const CHART_COLORS: string[] = [
  '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
  '#FFEAA7', '#DDA0DD', '#FF8C94', '#A8E6CF',
  '#FFD93D', '#6BCB77', '#4D96FF', '#F48484',
];

代码解读

  • enum:枚举类型,定义支出/收入
  • interface:接口,定义数据结构
  • 默认分类用了 Emoji 图标,更直观
  • CHART_COLORS:图表配色方案

四、数据服务:统一管理数据

4.1 创建服务文件

右键 ets → New → Directory,命名为 services

新建 FinanceDataService.ets

4.2 单例服务类

// services/FinanceDataService.ets

import { preferences } from '@kit.ArkData';
import {
  TransactionType, Transaction, Category,
  makeCategory, makeTransaction,
  DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES
} from '../models/FinanceModels';

// 单例服务类
export class FinanceDataService {
  private static instance: FinanceDataService;
  private store: preferences.Preferences | null = null;
  private categories: Category[] = [];
  private transactions: Transaction[] = [];
  private listeners: (() => void)[] = [];

  private constructor() {}

  static getInstance(): FinanceDataService {
    if (!FinanceDataService.instance) {
      FinanceDataService.instance = new FinanceDataService();
    }
    return FinanceDataService.instance;
  }

  // 初始化
  async init(context: Context): Promise<void> {
    this.store = await preferences.getPreferences(context, 'finance_data');
    await this.loadData();
  }

  // 加载数据
  private async loadData(): Promise<void> {
    // 加载分类
    const catJson = await this.store!.get('categories', '') as string;
    if (catJson) {
      this.categories = JSON.parse(catJson);
    } else {
      this.categories = [...DEFAULT_EXPENSE_CATEGORIES, ...DEFAULT_INCOME_CATEGORIES];
      await this.saveCategories();
    }

    // 加载交易记录
    const txJson = await this.store!.get('transactions', '') as string;
    if (txJson) {
      this.transactions = JSON.parse(txJson);
    }
  }

  // 注册监听器
  registerListener(callback: () => void): void {
    if (this.listeners.indexOf(callback) < 0) {
      this.listeners.push(callback);
    }
  }

  // 通知数据变化
  private notifyDataChanged(): void {
    for (const cb of this.listeners) {
      cb();
    }
  }

  // 保存分类
  private async saveCategories(): Promise<void> {
    await this.store!.put('categories', JSON.stringify(this.categories));
    await this.store!.flush();
    this.notifyDataChanged();
  }

  // 保存交易
  private async saveTransactions(): Promise<void> {
    await this.store!.put('transactions', JSON.stringify(this.transactions));
    await this.store!.flush();
    this.notifyDataChanged();
  }

  // 获取分类
  getCategories(type?: TransactionType): Category[] {
    if (type === undefined) return this.categories;
    return this.categories.filter(cat => cat.type === type);
  }

  // 根据ID获取分类
  getCategoryById(id: number): Category | undefined {
    return this.categories.find(cat => cat.id === id);
  }

  // 获取所有交易
  getAllTransactions(): Transaction[] {
    return this.transactions;
  }

  // 添加交易
  async addTransaction(type: TransactionType, amount: number, categoryId: number, note: string, date: string): Promise<Transaction> {
    const tx: Transaction = {
      id: Date.now(),  // 用时间戳做ID
      type, amount, categoryId, note, date
    };
    this.transactions.push(tx);
    await this.saveTransactions();
    return tx;
  }

  // 删除交易
  async deleteTransaction(id: number): Promise<void> {
    this.transactions = this.transactions.filter(tx => tx.id !== id);
    await this.saveTransactions();
  }

  // 获取月度统计
  getMonthlySummary(year: number, month: number) {
    const prefix = `${year}-${String(month).padStart(2, '0')}`;
    const monthTxs = this.transactions.filter(tx => tx.date.startsWith(prefix));
    
    let totalIncome = 0;
    let totalExpense = 0;
    for (const tx of monthTxs) {
      if (tx.type === TransactionType.INCOME) {
        totalIncome += tx.amount;
      } else {
        totalExpense += tx.amount;
      }
    }
    
    return {
      totalIncome,
      totalExpense,
      balance: totalIncome - totalExpense
    };
  }

  // 获取分类占比
  getCategoryBreakdown(year: number, month: number, type: TransactionType) {
    const prefix = `${year}-${String(month).padStart(2, '0')}`;
    const monthTxs = this.transactions.filter(tx => 
      tx.date.startsWith(prefix) && tx.type === type
    );

    const total = monthTxs.reduce((sum, tx) => sum + tx.amount, 0);
    if (total === 0) return [];

    const catMap = new Map<number, number>();
    for (const tx of monthTxs) {
      catMap.set(tx.categoryId, (catMap.get(tx.categoryId) || 0) + tx.amount);
    }

    const result = [];
    catMap.forEach((amount, catId) => {
      const cat = this.getCategoryById(catId);
      if (cat) {
        result.push({
          category: cat,
          amount,
          percentage: Math.round((amount / total) * 10000) / 100
        });
      }
    });

    return result.sort((a, b) => b.amount - a.amount);
  }
}

重点理解

  1. 单例模式:全局只有一个实例,数据共享
  2. Preferences:轻量级本地存储,类似 SharedPreferences
  3. 监听器:数据变化时自动通知页面刷新

五、首页:仪表盘设计

5.1 打开 Index.ets

找到 entry/src/main/ets/pages/Index.ets,清空内容,从头开始写。

5.2 页面基本结构

// pages/Index.ets

import { router } from '@kit.ArkUI';
import { TransactionType, CHART_COLORS } from '../models/FinanceModels';
import { FinanceDataService } from '../services/FinanceDataService';

@Entry
@Component
struct Index {
  private service = FinanceDataService.getInstance();
  
  @State currentYear: number = 0;
  @State currentMonth: number = 0;
  @State summary = { totalIncome: 0, totalExpense: 0, balance: 0 };
  @State expenseBreakdown: any[] = [];

  aboutToAppear(): void {
    const now = new Date();
    this.currentYear = now.getFullYear();
    this.currentMonth = now.getMonth() + 1;
    this.refreshData();
    this.service.registerListener(() => this.refreshData());
  }

  refreshData(): void {
    this.summary = this.service.getMonthlySummary(this.currentYear, this.currentMonth);
    this.expenseBreakdown = this.service.getCategoryBreakdown(
      this.currentYear, this.currentMonth, TransactionType.EXPENSE
    );
  }

  changeMonth(delta: number): void {
    this.currentMonth += delta;
    if (this.currentMonth > 12) {
      this.currentMonth = 1;
      this.currentYear++;
    } else if (this.currentMonth < 1) {
      this.currentMonth = 12;
      this.currentYear--;
    }
    this.refreshData();
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Text('💰 个人记账')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .margin({ left: 16 })
        Blank()
      }
      .width('100%')
      .height(56)
      .backgroundColor('#4ECDC4')
      .padding({ right: 16 })

      // 月份选择
      Row() {
        Text('<')
          .fontSize(22)
          .fontColor('#4ECDC4')
          .onClick(() => this.changeMonth(-1))
        
        Text(`${this.currentYear}${this.currentMonth}`)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .margin({ left: 24, right: 24 })
        
        Text('>')
          .fontSize(22)
          .fontColor('#4ECDC4')
          .onClick(() => this.changeMonth(1))
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .padding({ top: 12, bottom: 12 })

      // 本月结余卡片
      Column() {
        Text('本月结余')
          .fontSize(14)
          .fontColor('#888888')
        
        Text(`¥${this.summary.balance.toFixed(2)}`)
          .fontSize(34)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.summary.balance >= 0 ? '#FF6B6B' : '#4ECDC4')
          .margin({ top: 4 })

        Row() {
          Column() {
            Text('收入').fontSize(12).fontColor('#888888')
            Text(`¥${this.summary.totalIncome.toFixed(2)}`)
              .fontSize(18)
              .fontWeight(FontWeight.Medium)
              .fontColor('#4ECDC4')
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Divider()
            .vertical(true)
            .height(36)
            .color('#E0E0E0')

          Column() {
            Text('支出').fontSize(12).fontColor('#888888')
            Text(`¥${this.summary.totalExpense.toFixed(2)}`)
              .fontSize(18)
              .fontWeight(FontWeight.Medium)
              .fontColor('#FF6B6B')
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('92%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding(20)
      .shadow({ radius: 6, color: '#18000000', offsetY: 2 })

      // 底部导航栏
      Row() {
        this.navButton('📊', '首页', true)
        this.navButton('➕', '记账', false)
        this.navButton('📋', '账单', false)
        this.navButton('📈', '统计', false)
      }
      .width('100%')
      .height(64)
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 4, color: '#20000000', offsetY: -2 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  navButton(icon: string, label: string, isActive: boolean) {
    Column() {
      Text(icon).fontSize(22)
      Text(label)
        .fontSize(10)
        .fontColor(isActive ? '#4ECDC4' : '#999999')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
  }
}

5.3 添加环形图组件

Index 组件之前,添加一个图表组件:

@Component
struct DonutChart {
  private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
  @Prop @Watch('onDataChanged') breakdown: any[] = [];
  @State isReady: boolean = false;

  onDataChanged(): void {
    if (this.isReady) this.drawChart();
  }

  drawChart(): void {
    const ctx = this.canvasContext;
    const cx = 110, cy = 110;
    const radius = 90, innerRadius = 55;

    ctx.clearRect(0, 0, 220, 220);

    if (this.breakdown.length === 0) {
      ctx.beginPath();
      ctx.arc(cx, cy, radius, 0, Math.PI * 2);
      ctx.fillStyle = '#F0F0F0';
      ctx.fill();
      ctx.beginPath();
      ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2);
      ctx.fillStyle = '#FFFFFF';
      ctx.fill();
      ctx.fillStyle = '#999999';
      ctx.textAlign = 'center';
      ctx.fillText('暂无数据', cx, cy + 5);
      return;
    }

    let startAngle = -Math.PI / 2;
    const total = this.breakdown.reduce((sum: number, b: any) => sum + b.amount, 0);

    for (let i = 0; i < this.breakdown.length; i++) {
      const item = this.breakdown[i];
      const sliceAngle = (item.amount / total) * Math.PI * 2;
      const endAngle = startAngle + sliceAngle;

      const path = new Path2D();
      path.arc(cx, cy, radius, startAngle, endAngle);
      path.lineTo(cx, cy);
      path.closePath();
      ctx.fillStyle = CHART_COLORS[i % CHART_COLORS.length];
      ctx.fill(path);

      startAngle = endAngle;
    }

    ctx.beginPath();
    ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2);
    ctx.fillStyle = '#FFFFFF';
    ctx.fill();

    ctx.fillStyle = '#333333';
    ctx.font = 'bold 16px';
    ctx.textAlign = 'center';
    ctx.fillText(`¥${total.toFixed(0)}`, cx, cy + 6);
  }

  build() {
    Canvas(this.canvasContext)
      .width(220)
      .height(220)
      .onReady(() => {
        this.isReady = true;
        this.drawChart();
      })
  }
}

在首页的 Scroll 中添加:

// 支出分布
Column() {
  Text('支出分布')
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
    .width('100%')

  DonutChart({ breakdown: this.expenseBreakdown })
    .margin({ top: 8 })

  Flex({ wrap: FlexWrap.Wrap }) {
    ForEach(this.expenseBreakdown, (item: any, index?: number) => {
      Row() {
        Row()
          .width(10)
          .height(10)
          .borderRadius(5)
          .backgroundColor(CHART_COLORS[(index as number) % CHART_COLORS.length])
        Text(` ${item.category.icon} ${item.category.name}`)
          .fontSize(12)
          .margin({ left: 4 })
        Text(` ${item.percentage}%`)
          .fontSize(12)
          .fontColor('#888888')
          .margin({ left: 4 })
      }
      .margin({ bottom: 4, right: 8 })
    })
  }
  .width('100%')
  .margin({ top: 10 })
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.padding(16)
.margin({ top: 12 })

六、记账页面

6.1 创建新页面

右键 pages 文件夹 → New → File,命名为 AddTransactionPage.ets

6.2 完整代码

// pages/AddTransactionPage.ets

import { router } from '@kit.ArkUI';
import { TransactionType, Category } from '../models/FinanceModels';
import { FinanceDataService } from '../services/FinanceDataService';

@Entry
@Component
struct AddTransactionPage {
  private service = FinanceDataService.getInstance();

  @State txType: TransactionType = TransactionType.EXPENSE;
  @State amount: string = '';
  @State selectedCategoryId: number = -1;
  @State txDate: string = '';
  @State note: string = '';
  @State categories: Category[] = [];

  aboutToAppear(): void {
    // 初始化日期为今天
    const now = new Date();
    this.txDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
    
    this.loadCategories();
  }

  loadCategories(): void {
    this.categories = this.service.getCategories(this.txType);
    if (this.selectedCategoryId < 0 && this.categories.length > 0) {
      this.selectedCategoryId = this.categories[0].id;
    }
  }

  switchType(type: TransactionType): void {
    this.txType = type;
    this.selectedCategoryId = -1;
    this.loadCategories();
  }

  async saveTransaction(): Promise<void> {
    const amountNum = parseFloat(this.amount);
    if (isNaN(amountNum) || amountNum <= 0 || this.selectedCategoryId < 0) return;

    await this.service.addTransaction(
      this.txType, amountNum, this.selectedCategoryId, this.note, this.txDate
    );
    router.back();
  }

  showDatePicker(): void {
    const parts = this.txDate.split('-');
    DatePickerDialog.show({
      start: new Date(2020, 0, 1),
      end: new Date(2035, 11, 31),
      selected: new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])),
      onDateAccept: (value: Date) => {
        this.txDate = `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
      }
    });
  }

  build() {
    Column() {
      // 顶部栏
      Row() {
        Text('< 返回')
          .fontSize(16)
          .fontColor('#666666')
          .onClick(() => router.back())
        Blank()
        Text('新增交易')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
        Blank()
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#FFFFFF')

      Scroll() {
        Column() {
          // 金额输入
          Row() {
            Text('¥').fontSize(28).fontWeight(FontWeight.Bold)
            TextInput({ placeholder: '0.00', text: this.amount })
              .fontSize(28)
              .fontWeight(FontWeight.Bold)
              .type(InputType.Number)
              .layoutWeight(1)
              .onChange(value => this.amount = value)
          }
          .width('92%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(12)
          .margin({ top: 16 })

          // 收入/支出切换
          Row() {
            Button('支出')
              .width(120)
              .height(44)
              .backgroundColor(this.txType === TransactionType.EXPENSE ? '#FF6B6B' : '#FFF0F0')
              .fontColor(this.txType === TransactionType.EXPENSE ? '#FFFFFF' : '#FF6B6B')
              .borderRadius(22)
              .onClick(() => this.switchType(TransactionType.EXPENSE))

            Blank()

            Button('收入')
              .width(120)
              .height(44)
              .backgroundColor(this.txType === TransactionType.INCOME ? '#4ECDC4' : '#F0FFFA')
              .fontColor(this.txType === TransactionType.INCOME ? '#FFFFFF' : '#4ECDC4')
              .borderRadius(22)
              .onClick(() => this.switchType(TransactionType.INCOME))
          }
          .width('92%')
          .margin({ top: 16 })

          // 分类选择
          Column() {
            Text('选择分类')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .width('100%')

            Grid() {
              ForEach(this.categories, (cat: Category) => {
                GridItem() {
                  Column() {
                    Text(cat.icon).fontSize(32)
                    Text(cat.name).fontSize(12).margin({ top: 4 })
                    if (this.selectedCategoryId === cat.id) {
                      Text('✓').fontSize(12).fontColor('#4ECDC4').margin({ top: 2 })
                    }
                  }
                  .width('100%')
                  .alignItems(HorizontalAlign.Center)
                  .padding({ top: 10, bottom: 10 })
                  .backgroundColor(this.selectedCategoryId === cat.id ? '#F0FFFA' : '#F8F8F8')
                  .borderRadius(10)
                  .onClick(() => this.selectedCategoryId = cat.id)
                }
              }, (cat: Category) => String(cat.id))
            }
            .columnsTemplate('1fr 1fr 1fr 1fr')
            .rowsGap(8)
            .columnsGap(8)
            .width('100%')
          }
          .width('92%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(16)
          .margin({ top: 12 })

          // 日期选择
          Row() {
            Text('日期').fontSize(15).fontColor('#666666')
            Blank()
            Text(this.txDate).fontSize(15).fontWeight(FontWeight.Medium).margin({ right: 8 })
            Text('📅').fontSize(20)
          }
          .width('92%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(16)
          .margin({ top: 12 })
          .onClick(() => this.showDatePicker())

          // 备注
          Row() {
            Text('备注').fontSize(15).fontColor('#666666').margin({ right: 12 })
            TextInput({ placeholder: '添加备注...', text: this.note })
              .layoutWeight(1)
              .onChange(value => this.note = value)
          }
          .width('92%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(16)
          .margin({ top: 12 })

          // 保存按钮
          Button('保存')
            .width('92%')
            .height(50)
            .backgroundColor('#4ECDC4')
            .borderRadius(25)
            .margin({ top: 24, bottom: 32 })
            .onClick(() => this.saveTransaction())
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .layoutWeight(1)
      .backgroundColor('#F5F5F5')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

七、账单列表页面

创建 TransactionListPage.ets

// pages/TransactionListPage.ets

import { router } from '@kit.ArkUI';
import { TransactionType, Transaction } from '../models/FinanceModels';
import { FinanceDataService } from '../services/FinanceDataService';

@Entry
@Component
struct TransactionListPage {
  private service = FinanceDataService.getInstance();
  
  @State filterType: number = -1;  // -1: 全部, 0: 支出, 1: 收入
  @State transactions: Transaction[] = [];

  aboutToAppear(): void {
    this.refreshList();
    this.service.registerListener(() => this.refreshList());
  }

  refreshList(): void {
    let allTxs = this.filterType === -1
      ? this.service.getAllTransactions()
      : this.service.getAllTransactions().filter(tx => tx.type === this.filterType);
    
    allTxs.sort((a, b) => b.id - a.id);
    this.transactions = allTxs;
  }

  async deleteTransaction(id: number): Promise<void> {
    await this.service.deleteTransaction(id);
    this.refreshList();
  }

  build() {
    Column() {
      // 顶部栏
      Row() {
        Text('< 返回')
          .fontSize(16)
          .fontColor('#666666')
          .onClick(() => router.back())
        Blank()
        Text('交易记录')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
        Blank()
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#FFFFFF')

      // 筛选标签
      Row() {
        this.filterTab('全部', -1)
        this.filterTab('支出', 0)
        this.filterTab('收入', 1)
      }
      .width('100%')
      .height(48)
      .backgroundColor('#FFFFFF')
      .padding({ left: 16, right: 16 })

      // 交易列表
      if (this.transactions.length === 0) {
        Column() {
          Text('暂无交易记录')
            .fontSize(16)
            .fontColor('#BBBBBB')
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      } else {
        List() {
          ForEach(this.transactions, (tx: Transaction) => {
            ListItem() {
              Row() {
                Text(this.service.getCategoryById(tx.categoryId)?.icon || '❓')
                  .fontSize(24)
                  .margin({ right: 12 })

                Column() {
                  Text(this.service.getCategoryById(tx.categoryId)?.name || '未知')
                    .fontSize(15)
                    .fontWeight(FontWeight.Medium)
                  if (tx.note) {
                    Text(tx.note)
                      .fontSize(12)
                      .fontColor('#888888')
                      .margin({ top: 2 })
                  }
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)

                Text(`${tx.type === TransactionType.EXPENSE ? '-' : '+'}¥${tx.amount.toFixed(2)}`)
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(tx.type === TransactionType.EXPENSE ? '#FF6B6B' : '#4ECDC4')
              }
              .width('100%')
              .padding({ left: 16, right: 16, top: 14, bottom: 14 })
              .onClick(() => {
                router.pushUrl({
                  url: 'pages/AddTransactionPage',
                  params: { editId: tx.id }
                });
              })
            }
            .swipeAction({
              end: this.deleteButton(tx.id)
            })
          })
        }
        .width('100%')
        .layoutWeight(1)
        .divider({ strokeWidth: 0.5, color: '#F0F0F0' })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  filterTab(label: string, type: number) {
    Column() {
      Text(label)
        .fontSize(15)
        .fontColor(this.filterType === type ? '#4ECDC4' : '#666666')
      Divider()
        .width(20)
        .height(3)
        .backgroundColor(this.filterType === type ? '#4ECDC4' : 'transparent')
        .borderRadius(2)
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.filterType = type;
      this.refreshList();
    })
  }

  @Builder
  deleteButton(txId: number) {
    Button('删除')
      .width(72)
      .height('100%')
      .backgroundColor('#FF6B6B')
      .fontColor('#FFFFFF')
      .onClick(() => this.deleteTransaction(txId))
  }
}

八、配置路由

8.1 添加页面路由

找到 entry/src/main/resources/base/profile/main_pages.json

{
  "src": [
    "pages/Index",
    "pages/AddTransactionPage",
    "pages/TransactionListPage"
  ]
}

8.2 修改底部导航

回到 Index.ets,更新导航栏点击事件:

@Builder
navButton(icon: string, label: string, target: string, isActive: boolean) {
  Column() {
    Text(icon).fontSize(22)
    Text(label)
      .fontSize(10)
      .fontColor(isActive ? '#4ECDC4' : '#999999')
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .onClick(() => {
    if (!isActive && target !== 'pages/Index') {
      router.pushUrl({ url: target });
    }
  })
}

// 底部栏更新为
Row() {
  this.navButton('📊', '首页', 'pages/Index', true)
  this.navButton('➕', '记账', 'pages/AddTransactionPage', false)
  this.navButton('📋', '账单', 'pages/TransactionListPage', false)
  this.navButton('📈', '统计', 'pages/StatisticsPage', false)
}

九、初始化数据服务

9.1 修改 EntryAbility.ets

// entry/src/main/ets/entryability/EntryAbility.ets

import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { FinanceDataService } from '../ets/services/FinanceDataService';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 初始化数据服务
    FinanceDataService.getInstance().init(this.context);
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        console.error('Failed to load content:', JSON.stringify(err));
        return;
      }
      console.info('Succeeded in loading content.');
    });
  }
}

十、运行测试

10.1 连接模拟器

  1. 点击 DevEco Studio 顶部工具栏的设备下拉框
  2. 选择 “Device Manager”
  3. 启动模拟器(建议使用 Phone 模拟器)
  4. 等待模拟器启动完成

10.2 运行应用

  1. 点击工具栏的 ▶️ 按钮(Run)
  2. 或按快捷键 Shift + F10
  3. 等待编译和安装
  4. 应用自动启动
    在这里插入图片描述

10.3 功能测试

✅ 测试清单:

  • 首页显示月度统计
  • 切换月份
  • 添加收入记录
  • 添加支出记录
  • 查看账单列表
  • 筛选收入/支出
  • 左滑删除记录
  • 查看支出分布图

十一、常见问题

Q1: Canvas 绑制不出来?

确保在 onReady 后才绘制:

Canvas(this.canvasContext)
  .onReady(() => {
    this.drawChart();  // ✅ 正确
  })

// ❌ 错误:直接在 aboutToAppear 绘制

Q2: 页面跳转失败?

检查路由配置:

// main_pages.json 必须包含所有页面
{
  "src": [
    "pages/Index",
    "pages/AddTransactionPage",
    "pages/TransactionListPage"
  ]
}

Q3: 数据不保存?

确保调用了 flush()

await this.store.put('key', value);
await this.store.flush();  // ✅ 必须!

Q4: 列表不刷新?

使用监听器:

aboutToAppear(): void {
  this.service.registerListener(() => this.refreshData());  // ✅
}

十二、下一步学习

完成基础功能后,可以继续扩展:

12.1 添加统计页面

参考首页的图表组件,创建柱状图和饼图。

12.2 添加分类管理

实现自定义分类、图标选择功能。

12.3 数据导出

将交易记录导出为 CSV 文件。

12.4 云端同步

接入华为云服务,实现数据备份。


总结

恭喜你完成了一个 HarmonyOS NEXT 应用!🎉

你学到了什么

知识点 掌握程度
ArkTS 基础语法 ⭐⭐⭐⭐⭐
组件化开发 ⭐⭐⭐⭐
Canvas 图表 ⭐⭐⭐
数据持久化 ⭐⭐⭐⭐
页面路由 ⭐⭐⭐⭐⭐

项目亮点

完整业务闭环:记账 → 查看 → 分析
美观界面:卡片式设计、渐变色彩
自定义图表:纯 Canvas 实现
响应式更新:监听器模式


本文适合初学者快速上手,帮你 30 分钟构建第一个鸿蒙应用。如有问题,欢迎评论区交流!

Logo

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

更多推荐