Files
billai/web/src/routes/analysis/+page.svelte
clz d813fe4307
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
fix: 分析页面切换日期后总收支统计不更新
onDateChange 函数重新请求数据后同步更新 backendTotalExpense
和 backendTotalIncome,修复切换日期范围后顶部统计卡片
仍显示旧数据的问题。
2026-03-04 12:17:05 +08:00

376 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { fetchBills, fetchMonthlyStats, checkHealth, type CleanedBill, type MonthlyStat } from '$lib/api';
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Card from '$lib/components/ui/card';
import { MonthRangePicker } from '$lib/components/ui/month-range-picker';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import Activity from '@lucide/svelte/icons/activity';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
// 分析组件
import {
OverviewCards,
DailyTrendChart,
CategoryRanking,
MonthlyTrend,
TopExpenses,
} from '$lib/components/analysis';
// 数据处理服务
import {
calculateCategoryStats,
calculateDailyExpenseData,
calculateTotalStats,
calculatePieChartData,
getTopExpenses
} from '$lib/services/analysis';
// 演示数据
import { demoRecords } from '$lib/data/demo';
// 分类数据
import { categories as allCategories } from '$lib/data/categories';
// 计算默认日期范围本月1日到今天
function getDefaultDates() {
const today = new Date();
return {
startYear: today.getFullYear(),
startMonth: today.getMonth() + 1,
startDay: 1,
endYear: today.getFullYear(),
endMonth: today.getMonth() + 1,
endDay: today.getDate(),
};
}
const defaultDates = getDefaultDates();
// 状态
let isLoading = $state(false);
let errorMessage = $state('');
let records: CleanedBill[] = $state([]);
let allRecords: CleanedBill[] = $state([]); // 全部账单数据(不受日期筛选,用于每日趋势图)
let monthlyStats: MonthlyStat[] = $state([]); // 月度统计(全部数据)
let isDemo = $state(false);
let serverAvailable = $state(true);
// 后端返回的聚合统计(准确的总支出/收入)
let backendTotalExpense = $state(0);
let backendTotalIncome = $state(0);
// 日期范围筛选 - 初始化为默认值
let startYear = $state(defaultDates.startYear);
let startMonth = $state(defaultDates.startMonth);
let startDay = $state(defaultDates.startDay);
let endYear = $state(defaultDates.endYear);
let endMonth = $state(defaultDates.endMonth);
let endDay = $state(defaultDates.endDay);
// 辅助函数:将年月日转换为日期字符串
function toDateString(year: number, month: number, day: number): string {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
// 派生分析数据(统一成 UIBill
let analysisRecords: UIBill[] = $derived.by(() => (isDemo ? demoRecords : records.map(cleanedBillToUIBill)));
let allAnalysisRecords: UIBill[] = $derived.by(() => (isDemo ? demoRecords : allRecords.map(cleanedBillToUIBill)));
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
// 使用后端返回的聚合统计(准确),如果没有则使用前端计算(作为后备)
let totalStats = $derived({
expense: backendTotalExpense > 0 ? backendTotalExpense : calculateTotalStats(analysisRecords).expense,
income: backendTotalIncome > 0 ? backendTotalIncome : calculateTotalStats(analysisRecords).income,
count: analysisRecords.length,
});
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
let topExpenses = $derived(getTopExpenses(analysisRecords, 10));
// 账单更新处理
function handleBillUpdated(updated: UIBill) {
// 在 records 中查找并更新对应的账单
const idx = records.findIndex(r =>
r.id === (updated as unknown as { id?: string }).id ||
(r.time === updated.time && r.merchant === updated.merchant && r.amount === updated.amount)
);
if (idx !== -1) {
// 更新后端格式的记录
records[idx] = {
...records[idx],
time: updated.time,
category: updated.category,
merchant: updated.merchant,
description: updated.description || '',
amount: updated.amount,
pay_method: updated.paymentMethod || '',
status: updated.status || records[idx].status,
remark: updated.remark || records[idx].remark,
};
// 触发响应式更新
records = [...records];
// 同时更新 allRecords如果账单在全部数据中
const allIdx = allRecords.findIndex(r =>
r.id === (updated as unknown as { id?: string }).id ||
(r.time === updated.time && r.merchant === updated.merchant)
);
if (allIdx !== -1) {
allRecords[allIdx] = records[idx];
allRecords = [...allRecords];
}
}
}
function handleBillDeleted(deleted: UIBill) {
const idx = records.findIndex(r =>
r.id === (deleted as unknown as { id?: string }).id ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (idx !== -1) {
records.splice(idx, 1);
records = [...records];
}
const allIdx = allRecords.findIndex(r =>
r.id === (deleted as unknown as { id?: string }).id ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (allIdx !== -1) {
allRecords.splice(allIdx, 1);
allRecords = [...allRecords];
}
if (deleted.incomeExpense === '支出') {
backendTotalExpense = Math.max(0, backendTotalExpense - deleted.amount);
} else if (deleted.incomeExpense === '收入') {
backendTotalIncome = Math.max(0, backendTotalIncome - deleted.amount);
}
}
// 分类列表按数据中出现次数排序
let sortedCategories = $derived(() => {
const categoryCounts = new Map<string, number>();
for (const record of analysisRecords) {
categoryCounts.set(record.category, (categoryCounts.get(record.category) || 0) + 1);
}
return [...allCategories].sort((a, b) => {
const countA = categoryCounts.get(a) || 0;
const countB = categoryCounts.get(b) || 0;
if (countA !== countB) return countB - countA;
return allCategories.indexOf(a) - allCategories.indexOf(b);
});
});
async function loadData() {
isLoading = true;
errorMessage = '';
isDemo = false;
try {
// 先检查服务器状态
serverAvailable = await checkHealth();
if (!serverAvailable) {
errorMessage = '服务器不可用';
return;
}
// 并行获取数据:筛选后的账单 + 全部账单 + 全部月度统计
const [billsResponse, allBillsResponse, monthlyResponse] = await Promise.all([
fetchBills({
page_size: 10000,
start_date: toDateString(startYear, startMonth, startDay),
end_date: toDateString(endYear, endMonth, endDay),
}),
fetchBills({ page_size: 10000 }), // 获取全部数据,不传日期筛选
fetchMonthlyStats(),
]);
// 处理账单数据
if (billsResponse.result && billsResponse.data) {
records = billsResponse.data.bills || [];
// 使用后端返回的聚合统计(准确的总支出/收入)
backendTotalExpense = billsResponse.data.total_expense || 0;
backendTotalIncome = billsResponse.data.total_income || 0;
if (records.length === 0) {
errorMessage = '暂无账单数据';
}
} else {
errorMessage = billsResponse.message || '加载失败';
}
// 处理全部账单数据(用于每日趋势图)
if (allBillsResponse.result && allBillsResponse.data) {
allRecords = allBillsResponse.data.bills || [];
}
// 处理月度统计数据
if (monthlyResponse.result && monthlyResponse.data) {
monthlyStats = monthlyResponse.data;
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '加载失败';
serverAvailable = false;
} finally {
isLoading = false;
}
}
// 日期变化时重新加载(只加载筛选后的数据,月度统计不变)
async function onDateChange() {
if (!isDemo) {
isLoading = true;
errorMessage = '';
try {
const response = await fetchBills({
page_size: 10000,
start_date: toDateString(startYear, startMonth, startDay),
end_date: toDateString(endYear, endMonth, endDay),
});
if (response.result && response.data) {
records = response.data.bills || [];
backendTotalExpense = response.data.total_expense || 0;
backendTotalIncome = response.data.total_income || 0;
if (records.length === 0) {
errorMessage = '暂无账单数据';
}
} else {
errorMessage = response.message || '加载失败';
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '加载失败';
} finally {
isLoading = false;
}
}
}
function loadDemoData() {
isDemo = true;
errorMessage = '';
}
// 页面加载时自动获取数据
onMount(() => {
loadData();
});
</script>
<svelte:head>
<title>数据分析 - BillAI</title>
</svelte:head>
<div class="space-y-6">
<!-- 页面标题 -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">数据分析</h1>
<p class="text-muted-foreground">可视化你的消费数据,洞察消费习惯</p>
</div>
<div class="flex items-center gap-3">
{#if isDemo}
<Badge variant="secondary" class="text-xs">
📊 示例数据
</Badge>
{:else}
<!-- 日期范围筛选 -->
<MonthRangePicker
bind:startYear
bind:startMonth
bind:startDay
bind:endYear
bind:endMonth
bind:endDay
onchange={onDateChange}
/>
{/if}
<Button variant="outline" size="icon" onclick={loadData} disabled={isLoading} title="刷新数据">
<RefreshCw class="h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
</Button>
</div>
</div>
<!-- 错误提示 -->
{#if errorMessage && !isDemo}
<div class="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{errorMessage}
</div>
{/if}
<!-- 加载中 -->
{#if isLoading}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<Loader2 class="h-16 w-16 text-muted-foreground mb-4 animate-spin" />
<p class="text-lg font-medium">正在加载数据...</p>
</Card.Content>
</Card.Root>
{:else if analysisRecords.length > 0}
<!-- 总览卡片 -->
<OverviewCards {totalStats} records={analysisRecords} />
<!-- 每日支出趋势图(按分类堆叠) - 使用全部数据 -->
<DailyTrendChart
records={allAnalysisRecords}
categories={sortedCategories()}
onUpdate={handleBillUpdated}
onDelete={handleBillDeleted}
/>
<div class="grid gap-6 lg:grid-cols-2">
<!-- 分类支出排行 -->
<CategoryRanking
{categoryStats}
{pieChartData}
totalExpense={totalStats.expense}
records={analysisRecords}
categories={sortedCategories()}
onUpdate={handleBillUpdated}
/>
<!-- 月度趋势 -->
<MonthlyTrend {monthlyStats} />
</div>
<!-- Top 10 支出 -->
<TopExpenses
records={topExpenses}
categories={sortedCategories()}
onUpdate={handleBillUpdated}
onDelete={handleBillDeleted}
/>
{:else}
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<BarChart3 class="h-16 w-16 text-muted-foreground mb-4" />
<p class="text-lg font-medium">
{#if !serverAvailable}
服务器不可用
{:else}
暂无账单数据
{/if}
</p>
<p class="text-sm text-muted-foreground mb-4">
{#if !serverAvailable}
请检查后端服务是否正常运行
{:else}
上传账单后可在此进行数据分析
{/if}
</p>
<Button variant="outline" onclick={loadDemoData}>
<Activity class="mr-2 h-4 w-4" />
查看示例数据
</Button>
</Card.Content>
</Card.Root>
{/if}
</div>