Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
onDateChange 函数重新请求数据后同步更新 backendTotalExpense 和 backendTotalIncome,修复切换日期范围后顶部统计卡片 仍显示旧数据的问题。
376 lines
13 KiB
Svelte
376 lines
13 KiB
Svelte
<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>
|