feat(analysis): 增强图表交互功能
- 分类支出排行: 饼图支持点击类别切换显示/隐藏,百分比动态重新计算 - 每日支出趋势: 图例支持点击切换类别显示,隐藏类别不参与堆叠计算 - Dialog列表: 添加列排序功能(时间/商家/描述/金额) - Dialog列表: 添加分页功能,每页10条(分类)/8条(每日) - 饼图hover效果: 扇形放大、阴影增强、中心显示详情
This commit is contained in:
129
web/src/lib/services/analysis.ts
Normal file
129
web/src/lib/services/analysis.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { CategoryStat, MonthlyStat, DailyExpenseData, TotalStats, PieChartDataItem } from '$lib/types/analysis';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
|
||||
/**
|
||||
* 计算分类统计
|
||||
*/
|
||||
export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
||||
const stats = new Map<string, { expense: number; income: number; count: number }>();
|
||||
|
||||
for (const r of records) {
|
||||
if (!stats.has(r.category)) {
|
||||
stats.set(r.category, { expense: 0, income: 0, count: 0 });
|
||||
}
|
||||
const s = stats.get(r.category)!;
|
||||
s.count++;
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
if (r.income_expense === '支出') {
|
||||
s.expense += amount;
|
||||
} else {
|
||||
s.income += amount;
|
||||
}
|
||||
}
|
||||
|
||||
return [...stats.entries()]
|
||||
.map(([category, data]) => ({ category, ...data }))
|
||||
.sort((a, b) => b.expense - a.expense);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算月度统计
|
||||
*/
|
||||
export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
||||
const stats = new Map<string, { expense: number; income: number }>();
|
||||
|
||||
for (const r of records) {
|
||||
const month = r.time.substring(0, 7); // YYYY-MM
|
||||
if (!stats.has(month)) {
|
||||
stats.set(month, { expense: 0, income: 0 });
|
||||
}
|
||||
const s = stats.get(month)!;
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
if (r.income_expense === '支出') {
|
||||
s.expense += amount;
|
||||
} else {
|
||||
s.income += amount;
|
||||
}
|
||||
}
|
||||
|
||||
return [...stats.entries()]
|
||||
.map(([month, data]) => ({ month, ...data }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每日支出数据(用于面积图)
|
||||
*/
|
||||
export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseData[] {
|
||||
const stats = new Map<string, number>();
|
||||
|
||||
for (const r of records) {
|
||||
if (r.income_expense !== '支出') continue;
|
||||
const date = r.time.substring(0, 10); // YYYY-MM-DD
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
stats.set(date, (stats.get(date) || 0) + amount);
|
||||
}
|
||||
|
||||
return [...stats.entries()]
|
||||
.map(([date, value]) => ({ date: new Date(date), value }))
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算总计统计
|
||||
*/
|
||||
export function calculateTotalStats(records: BillRecord[]): TotalStats {
|
||||
return {
|
||||
expense: records
|
||||
.filter(r => r.income_expense === '支出')
|
||||
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
|
||||
income: records
|
||||
.filter(r => r.income_expense === '收入')
|
||||
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
|
||||
count: records.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算百分比
|
||||
*/
|
||||
export function getPercentage(value: number, total: number): number {
|
||||
return total > 0 ? (value / total) * 100 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算饼状图数据
|
||||
*/
|
||||
export function calculatePieChartData(
|
||||
categoryStats: CategoryStat[],
|
||||
totalExpense: number
|
||||
): PieChartDataItem[] {
|
||||
return categoryStats
|
||||
.filter(s => s.expense > 0)
|
||||
.map((stat, i) => ({
|
||||
category: stat.category,
|
||||
value: stat.expense,
|
||||
color: pieColors[i % pieColors.length],
|
||||
percentage: getPercentage(stat.expense, totalExpense)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Top N 支出记录
|
||||
*/
|
||||
export function getTopExpenses(records: BillRecord[], n: number = 10): BillRecord[] {
|
||||
return records
|
||||
.filter(r => r.income_expense === '支出')
|
||||
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计支出/收入笔数
|
||||
*/
|
||||
export function countByType(records: BillRecord[], type: '支出' | '收入'): number {
|
||||
return records.filter(r => r.income_expense === type).length;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user