引言
账单多了就需要搜索和筛选。本篇实现:按关键词搜索、按分类筛选、月度分类统计图表。
一、搜索功能
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;
}
总结
本篇实现了:
- 搜索:按备注和分类关键词实时过滤
- 分类筛选:横向滚动标签,多条件联动
- 统计页面:月度总览 + 分类排行进度条
- 搜索+筛选联动:两个条件同时生效
下篇实现 Preferences 本地数据持久化。