Harmony鸿蒙实战应用5:随手账本——搜索筛选与分类统计

引言

账单多了就需要搜索和筛选。本篇实现:按关键词搜索、按分类筛选、月度分类统计图表。


一、搜索功能

1.1 搜索栏

typescript 复制代码
@State searchKeyword: string = '';
@State searchResults: BillItem[] = [];

@Builder
SearchBar() {
  Row() {
    TextInput({ placeholder: '搜索备注、分类...', text: this.searchKeyword })
      .height(40)
      .layoutWeight(1)
      .padding({ left: 12 })
      .onChange((val) => {
        this.searchKeyword = val;
        this.doSearch();
      })
    
    if (this.searchKeyword.length > 0) {
      Button('取消')
        .type(ButtonType.NORMAL)
        .fontColor('#6C63FF')
        .fontSize(14)
        .onClick(() => {
          this.searchKeyword = '';
          this.searchResults = [];
        })
    }
  }
  .width('100%')
  .height(48)
  .backgroundColor(Color.White)
  .borderRadius(24)
  .padding({ left: 16, right: 8 })
  .shadow({ radius: 2, color: 'rgba(0,0,0,0.05)' })
}

1.2 搜索逻辑

typescript 复制代码
private doSearch() {
  const keyword = this.searchKeyword.trim().toLowerCase();
  if (!keyword) {
    this.searchResults = [];
    return;
  }
  
  this.searchResults = this.monthBills.filter(b => {
    return b.note.toLowerCase().includes(keyword) ||
           b.category.toLowerCase().includes(keyword);
  });
}

搜索效果------输入关键词"买书"实时过滤,列表只剩匹配的购物账单,搜索框右侧出现"取消":


二、分类筛选

2.1 筛选标签栏

typescript 复制代码
@State selectedFilter: string = '全部';

@Builder
FilterBar() {
  Scroll({ scrollable: ScrollDirection.Horizontal }) {
    Row() {
      // "全部"标签
      this.FilterChip('全部')
      
      ForEach(this.allCategories(), (cat: string) => {
        this.FilterChip(cat)
      })
    }
    .padding({ left: 16, right: 16 })
  }
  .height(48)
  .scrollBarWidth(0)
}

@Builder
FilterChip(label: string) {
  Text(label)
    .fontSize(14)
    .fontColor(label === this.selectedFilter ? Color.White : '#333')
    .padding({ left: 16, right: 16, top: 6, bottom: 6 })
    .backgroundColor(label === this.selectedFilter ? '#6C63FF' : '#F0F0F0')
    .borderRadius(16)
    .margin({ right: 8 })
    .onClick(() => {
      this.selectedFilter = label;
    })
}

private allCategories(): string[] {
  const cats = new Set(this.monthBills.map(b => b.category));
  return Array.from(cats);
}

// 筛选后的账单列表
private get filteredBills(): BillItem[] {
  let bills = this.monthBills;
  if (this.selectedFilter !== '全部') {
    bills = bills.filter(b => b.category === this.selectedFilter);
  }
  if (this.searchKeyword) {
    bills = bills.filter(b => {
      const kw = this.searchKeyword.toLowerCase();
      return b.note.toLowerCase().includes(kw) || b.category.toLowerCase().includes(kw);
    });
  }
  return bills;
}

分类筛选效果------选中"餐饮"标签(紫色高亮),列表只显示餐饮账单,标签栏横向滚动容纳所有分类:


三、分类统计面板

3.1 统计布局

#mermaid-svg-iJFge9mJ0oYDTdVi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iJFge9mJ0oYDTdVi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iJFge9mJ0oYDTdVi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iJFge9mJ0oYDTdVi .error-icon{fill:#552222;}#mermaid-svg-iJFge9mJ0oYDTdVi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iJFge9mJ0oYDTdVi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iJFge9mJ0oYDTdVi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iJFge9mJ0oYDTdVi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iJFge9mJ0oYDTdVi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iJFge9mJ0oYDTdVi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iJFge9mJ0oYDTdVi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iJFge9mJ0oYDTdVi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iJFge9mJ0oYDTdVi .marker.cross{stroke:#333333;}#mermaid-svg-iJFge9mJ0oYDTdVi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iJFge9mJ0oYDTdVi p{margin:0;}#mermaid-svg-iJFge9mJ0oYDTdVi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iJFge9mJ0oYDTdVi .cluster-label text{fill:#333;}#mermaid-svg-iJFge9mJ0oYDTdVi .cluster-label span{color:#333;}#mermaid-svg-iJFge9mJ0oYDTdVi .cluster-label span p{background-color:transparent;}#mermaid-svg-iJFge9mJ0oYDTdVi .label text,#mermaid-svg-iJFge9mJ0oYDTdVi span{fill:#333;color:#333;}#mermaid-svg-iJFge9mJ0oYDTdVi .node rect,#mermaid-svg-iJFge9mJ0oYDTdVi .node circle,#mermaid-svg-iJFge9mJ0oYDTdVi .node ellipse,#mermaid-svg-iJFge9mJ0oYDTdVi .node polygon,#mermaid-svg-iJFge9mJ0oYDTdVi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iJFge9mJ0oYDTdVi .rough-node .label text,#mermaid-svg-iJFge9mJ0oYDTdVi .node .label text,#mermaid-svg-iJFge9mJ0oYDTdVi .image-shape .label,#mermaid-svg-iJFge9mJ0oYDTdVi .icon-shape .label{text-anchor:middle;}#mermaid-svg-iJFge9mJ0oYDTdVi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iJFge9mJ0oYDTdVi .rough-node .label,#mermaid-svg-iJFge9mJ0oYDTdVi .node .label,#mermaid-svg-iJFge9mJ0oYDTdVi .image-shape .label,#mermaid-svg-iJFge9mJ0oYDTdVi .icon-shape .label{text-align:center;}#mermaid-svg-iJFge9mJ0oYDTdVi .node.clickable{cursor:pointer;}#mermaid-svg-iJFge9mJ0oYDTdVi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iJFge9mJ0oYDTdVi .arrowheadPath{fill:#333333;}#mermaid-svg-iJFge9mJ0oYDTdVi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iJFge9mJ0oYDTdVi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iJFge9mJ0oYDTdVi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iJFge9mJ0oYDTdVi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iJFge9mJ0oYDTdVi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iJFge9mJ0oYDTdVi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iJFge9mJ0oYDTdVi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iJFge9mJ0oYDTdVi .cluster text{fill:#333;}#mermaid-svg-iJFge9mJ0oYDTdVi .cluster span{color:#333;}#mermaid-svg-iJFge9mJ0oYDTdVi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iJFge9mJ0oYDTdVi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iJFge9mJ0oYDTdVi rect.text{fill:none;stroke-width:0;}#mermaid-svg-iJFge9mJ0oYDTdVi .icon-shape,#mermaid-svg-iJFge9mJ0oYDTdVi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iJFge9mJ0oYDTdVi .icon-shape p,#mermaid-svg-iJFge9mJ0oYDTdVi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iJFge9mJ0oYDTdVi .icon-shape .label rect,#mermaid-svg-iJFge9mJ0oYDTdVi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iJFge9mJ0oYDTdVi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iJFge9mJ0oYDTdVi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iJFge9mJ0oYDTdVi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 统计面板
本月总览
分类占比
总收入
总支出
结余
每类金额+百分比
横向进度条

3.2 分类统计计算

typescript 复制代码
interface CategoryStats {
  category: string;
  amount: number;
  percentage: number;
  type: BillType;
}

private calculateCategoryStats(): CategoryStats[] {
  const expenseBills = this.monthBills.filter(b => b.type === BillType.EXPENSE);
  const totalExpense = expenseBills.reduce((s, b) => s + b.amount, 0);
  
  // 按分类汇总
  const groups = new Map<string, number>();
  for (const bill of expenseBills) {
    const cur = groups.get(bill.category) || 0;
    groups.set(bill.category, cur + bill.amount);
  }
  
  return Array.from(groups.entries())
    .map(([category, amount]) => ({
      category,
      amount,
      percentage: totalExpense > 0 ? amount / totalExpense * 100 : 0,
      type: BillType.EXPENSE
    }))
    .sort((a, b) => b.amount - a.amount);
}

3.3 统计UI

typescript 复制代码
@Builder
CategoryStatsView() {
  Column() {
    Text('分类支出排行')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .width('100%')
      .padding({ bottom: 16 })
    
    ForEach(this.calculateCategoryStats(), (stat: CategoryStats) => {
      Row() {
        // 分类名
        Text(this.getCategoryEmoji(stat.category) + ' ' + stat.category)
          .width(80)
          .fontSize(14)
        
        // 进度条
        Stack() {
          // 背景条
          Column()
            .width('100%')
            .height(8)
            .backgroundColor('#F0F0F0')
            .borderRadius(4)
          
          // 进度
          Column()
            .width(`${Math.max(stat.percentage, 2)}%`)
            .height(8)
            .backgroundColor('#6C63FF')
            .borderRadius(4)
        }
        .layoutWeight(1)
        .margin({ left: 8, right: 8 })
        
        // 金额和占比
        Text(`¥${stat.amount.toFixed(0)}`)
          .width(70)
          .fontSize(14)
          .textAlign(TextAlign.End)
        Text(`${stat.percentage.toFixed(1)}%`)
          .width(50)
          .fontSize(12)
          .fontColor('#999')
          .textAlign(TextAlign.End)
      }
      .width('100%')
      .padding({ vertical: 6 })
    })
  }
  .width('100%')
  .padding(16)
  .backgroundColor(Color.White)
  .borderRadius(12)
}

四、统计Tab页

typescript 复制代码
// pages/StatsPage.ets
@Entry
@Component
struct StatsPage {
  @State currentMonth: string = this.getCurrentMonth();
  @State monthBills: BillItem[] = [];

  aboutToAppear() {
    this.loadData();
  }

  build() {
    Column() {
      // 月度总收入/支出/结余
      this.MonthOverview()
      
      Scroll() {
        Column() {
          // 分类统计
          this.CategoryStatsView()
        }
        .width('100%')
        .padding(16)
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  MonthOverview() {
    const income = this.monthBills
      .filter(b => b.type === BillType.INCOME)
      .reduce((s, b) => s + b.amount, 0);
    const expense = this.monthBills
      .filter(b => b.type === BillType.EXPENSE)
      .reduce((s, b) => s + b.amount, 0);
    
    Row() {
      this.StatItem('收入', `¥${income.toFixed(2)}`, '#44BB44')
      this.StatItem('支出', `¥${expense.toFixed(2)}`, '#FF4444')
      this.StatItem('结余', `¥${(income - expense).toFixed(2)}`, '#6C63FF')
    }
    .width('100%')
    .padding(20)
    .backgroundColor(Color.White)
  }

  @Builder
  StatItem(label: string, value: string, color: string) {
    Column() {
      Text(value)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor(color)
      Text(label)
        .fontSize(13)
        .fontColor('#999')
        .margin({ top: 4 })
    }
    .layoutWeight(1)
  }
}

统计页实际效果------月度收入/支出/结余总览 + 分类支出排行进度条(购物 ¥299 占 68.9% 居首):


搜索与筛选联动逻辑

#mermaid-svg-Jq8HHcgVsumB66fO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Jq8HHcgVsumB66fO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Jq8HHcgVsumB66fO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Jq8HHcgVsumB66fO .error-icon{fill:#552222;}#mermaid-svg-Jq8HHcgVsumB66fO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Jq8HHcgVsumB66fO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Jq8HHcgVsumB66fO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Jq8HHcgVsumB66fO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Jq8HHcgVsumB66fO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Jq8HHcgVsumB66fO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Jq8HHcgVsumB66fO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Jq8HHcgVsumB66fO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Jq8HHcgVsumB66fO .marker.cross{stroke:#333333;}#mermaid-svg-Jq8HHcgVsumB66fO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Jq8HHcgVsumB66fO p{margin:0;}#mermaid-svg-Jq8HHcgVsumB66fO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Jq8HHcgVsumB66fO .cluster-label text{fill:#333;}#mermaid-svg-Jq8HHcgVsumB66fO .cluster-label span{color:#333;}#mermaid-svg-Jq8HHcgVsumB66fO .cluster-label span p{background-color:transparent;}#mermaid-svg-Jq8HHcgVsumB66fO .label text,#mermaid-svg-Jq8HHcgVsumB66fO span{fill:#333;color:#333;}#mermaid-svg-Jq8HHcgVsumB66fO .node rect,#mermaid-svg-Jq8HHcgVsumB66fO .node circle,#mermaid-svg-Jq8HHcgVsumB66fO .node ellipse,#mermaid-svg-Jq8HHcgVsumB66fO .node polygon,#mermaid-svg-Jq8HHcgVsumB66fO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Jq8HHcgVsumB66fO .rough-node .label text,#mermaid-svg-Jq8HHcgVsumB66fO .node .label text,#mermaid-svg-Jq8HHcgVsumB66fO .image-shape .label,#mermaid-svg-Jq8HHcgVsumB66fO .icon-shape .label{text-anchor:middle;}#mermaid-svg-Jq8HHcgVsumB66fO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Jq8HHcgVsumB66fO .rough-node .label,#mermaid-svg-Jq8HHcgVsumB66fO .node .label,#mermaid-svg-Jq8HHcgVsumB66fO .image-shape .label,#mermaid-svg-Jq8HHcgVsumB66fO .icon-shape .label{text-align:center;}#mermaid-svg-Jq8HHcgVsumB66fO .node.clickable{cursor:pointer;}#mermaid-svg-Jq8HHcgVsumB66fO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Jq8HHcgVsumB66fO .arrowheadPath{fill:#333333;}#mermaid-svg-Jq8HHcgVsumB66fO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Jq8HHcgVsumB66fO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Jq8HHcgVsumB66fO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jq8HHcgVsumB66fO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Jq8HHcgVsumB66fO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jq8HHcgVsumB66fO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Jq8HHcgVsumB66fO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Jq8HHcgVsumB66fO .cluster text{fill:#333;}#mermaid-svg-Jq8HHcgVsumB66fO .cluster span{color:#333;}#mermaid-svg-Jq8HHcgVsumB66fO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Jq8HHcgVsumB66fO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Jq8HHcgVsumB66fO rect.text{fill:none;stroke-width:0;}#mermaid-svg-Jq8HHcgVsumB66fO .icon-shape,#mermaid-svg-Jq8HHcgVsumB66fO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jq8HHcgVsumB66fO .icon-shape p,#mermaid-svg-Jq8HHcgVsumB66fO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Jq8HHcgVsumB66fO .icon-shape .label rect,#mermaid-svg-Jq8HHcgVsumB66fO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jq8HHcgVsumB66fO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Jq8HHcgVsumB66fO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Jq8HHcgVsumB66fO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有



全部账单
是否有关键词?
按备注/分类搜索
保留全部
是否选中分类?
按分类筛选
显示全部
最终列表

typescript 复制代码
// 搜索+筛选联动(unsingleton getter)
get filteredBills(): BillItem[] {
  let bills = this.monthBills;
  
  // 关键词搜索
  if (this.searchKeyword.trim()) {
    const kw = this.searchKeyword.toLowerCase().trim();
    bills = bills.filter(b =>
      b.note.toLowerCase().includes(kw) ||
      b.category.toLowerCase().includes(kw)
    );
  }
  
  // 分类筛选
  if (this.selectedFilter !== '全部') {
    bills = bills.filter(b => b.category === this.selectedFilter);
  }
  
  return bills;
}

总结

本篇实现了:

  1. 搜索:按备注和分类关键词实时过滤
  2. 分类筛选:横向滚动标签,多条件联动
  3. 统计页面:月度总览 + 分类排行进度条
  4. 搜索+筛选联动:两个条件同时生效

下篇实现 Preferences 本地数据持久化。

相关推荐
2501_9197490322 分钟前
华为鸿蒙免费自拍对比APP—小羊自拍
华为·harmonyos·鸿蒙
贾伟康23 分钟前
【时光清单|02】HarmonyOS ArkTS 习惯打卡实战:处理连续天数、补签和日期切换
harmonyos·arkts·数据持久化·日期处理·习惯打卡
大锅盖13 小时前
围绕夜航深蓝与霓虹青的跨境画卷构建 HarmonyOS ArkUI 出境旅行向导平台
华为·harmonyos
骐骥111 小时前
学习:使用postMessage()建立应用侧与前端页面的数据通道
harmonyos·鸿蒙·jsbridge·postmessage·数据通道
HwJack2018 小时前
鸿蒙开发ArkData 全景与选型决策:三种存储到底用哪个
华为·harmonyos
czhm5719 小时前
基于 HarmonyOS ArkTS API 24,工具函数抽离业务计算逻辑,状态颜色判断解耦组件代码
harmonyos·arkts
lilian23320 小时前
HarmonyOS 7 新特性(九)|碰一碰精准分享与互动卡片
华为·harmonyos
lilian23320 小时前
HarmonyOS 7 新特性(十)|DevEco Code 与 DevEco CLI AI 开发提效
人工智能·华为·harmonyos
袁震21 小时前
HarmonyOS 应用包体积优化与上架自检实战:从 76.2MB 到 3.6MB
java·华为·性能优化·harmonyos