feat: 完善项目架构并增强分析页面功能

- 新增项目文档和 Docker 配置
  - 添加 README.md 和 TODO.md 项目文档
  - 为各服务添加 Dockerfile 和 docker-compose 配置

- 重构后端架构
  - 新增 adapter 层(HTTP/Python 适配器)
  - 新增 repository 层(数据访问抽象)
  - 新增 router 模块统一管理路由
  - 新增账单处理 handler

- 扩展前端 UI 组件库
  - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件
  - 集成 shadcn-svelte 组件库

- 增强分析页面功能
  - 添加时间范围筛选器(支持本月默认值)
  - 修复 DateRangePicker 默认值显示问题
  - 优化数据获取和展示逻辑

- 完善分析器服务
  - 新增 FastAPI 服务接口
  - 改进账单清理器实现
This commit is contained in:
2026-01-10 01:15:52 +08:00
parent 94f8ea12e6
commit 087ae027cc
96 changed files with 4301 additions and 482 deletions

View File

@@ -1,11 +1,16 @@
<script lang="ts">
import { fetchBillContent, type BillRecord } from '$lib/api';
import { onMount } from 'svelte';
import { fetchBills, checkHealth, type CleanedBill } from '$lib/api';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card';
import { DateRangePicker } from '$lib/components/ui/date-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 Calendar from '@lucide/svelte/icons/calendar';
// 分析组件
import {
@@ -14,7 +19,6 @@
CategoryRanking,
MonthlyTrend,
TopExpenses,
EmptyState
} from '$lib/components/analysis';
// 数据处理服务
@@ -32,61 +36,119 @@
// 分类数据
import { categories as allCategories } from '$lib/data/categories';
// 计算默认日期范围(本月)
function getDefaultDates() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
const endDate = today.toISOString().split('T')[0];
return { startDate, endDate };
}
const defaultDates = getDefaultDates();
// 状态
let fileName = $state('');
let isLoading = $state(false);
let errorMessage = $state('');
let records: BillRecord[] = $state([]);
let records: CleanedBill[] = $state([]);
let isDemo = $state(false);
let serverAvailable = $state(true);
// 派生数据
let categoryStats = $derived(calculateCategoryStats(records));
let monthlyStats = $derived(calculateMonthlyStats(records));
let dailyExpenseData = $derived(calculateDailyExpenseData(records));
let totalStats = $derived(calculateTotalStats(records));
// 时间范围筛选 - 初始化为默认值
let startDate: string = $state(defaultDates.startDate);
let endDate: string = $state(defaultDates.endDate);
// 将 CleanedBill 转换为分析服务需要的格式
function toAnalysisRecords(bills: CleanedBill[]) {
return bills.map(bill => ({
time: bill.time,
category: bill.category,
merchant: bill.merchant,
description: bill.description,
income_expense: bill.income_expense,
amount: String(bill.amount),
payment_method: bill.pay_method,
status: bill.status,
remark: bill.remark,
needs_review: bill.review_level,
}));
}
// 派生分析数据
let analysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(records));
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
let monthlyStats = $derived(calculateMonthlyStats(analysisRecords));
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
let totalStats = $derived(calculateTotalStats(analysisRecords));
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
let topExpenses = $derived(getTopExpenses(records, 10));
let topExpenses = $derived(getTopExpenses(analysisRecords, 10));
// 分类列表按数据中出现次数排序(出现次数多的优先)
// 分类列表按数据中出现次数排序
let sortedCategories = $derived(() => {
// 统计每个分类的记录数量
const categoryCounts = new Map<string, number>();
for (const record of records) {
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() {
if (!fileName) return;
isLoading = true;
errorMessage = '';
isDemo = false;
try {
records = await fetchBillContent(fileName);
// 先检查服务器状态
serverAvailable = await checkHealth();
if (!serverAvailable) {
errorMessage = '服务器不可用';
return;
}
// 获取账单数据(带时间范围筛选)
const response = await fetchBills({
page_size: 10000,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
if (response.result && response.data) {
records = response.data.bills || [];
if (records.length === 0) {
errorMessage = '暂无账单数据';
}
} else {
errorMessage = response.message || '加载失败';
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '加载失败';
serverAvailable = false;
} finally {
isLoading = false;
}
}
// 日期变化时重新加载
function onDateChange() {
if (!isDemo) {
loadData();
}
}
function loadDemoData() {
isDemo = true;
errorMessage = '';
records = demoRecords;
}
// 页面加载时自动获取数据
onMount(() => {
loadData();
});
</script>
<svelte:head>
@@ -95,55 +157,52 @@
<div class="space-y-6">
<!-- 页面标题 -->
<div class="flex items-center justify-between">
<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>
{#if isDemo}
<Badge variant="secondary" class="text-xs">
📊 示例数据
</Badge>
{/if}
</div>
<!-- 搜索栏 -->
<div class="flex gap-3">
<div class="relative flex-1">
<BarChart3 class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="输入文件名..."
class="pl-10"
bind:value={fileName}
onkeydown={(e) => e.key === 'Enter' && loadData()}
/>
</div>
<Button onclick={loadData} disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
分析中
<div class="flex items-center gap-3">
{#if isDemo}
<Badge variant="secondary" class="text-xs">
📊 示例数据
</Badge>
{:else}
<BarChart3 class="mr-2 h-4 w-4" />
分析
<!-- 时间范围筛选 -->
<DateRangePicker
bind:startDate
bind:endDate
onchange={onDateChange}
/>
{/if}
</Button>
<Button variant="outline" size="icon" onclick={loadData} disabled={isLoading} title="刷新数据">
<RefreshCw class="h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
</Button>
</div>
</div>
<!-- 错误提示 -->
{#if errorMessage}
{#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 records.length > 0}
<!-- 加载中 -->
{#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} />
<OverviewCards {totalStats} records={analysisRecords} />
<!-- 每日支出趋势图(按分类堆叠) -->
<DailyTrendChart bind:records categories={sortedCategories()} />
<DailyTrendChart records={analysisRecords} categories={sortedCategories()} />
<div class="grid gap-6 lg:grid-cols-2">
<!-- 分类支出排行 -->
@@ -151,7 +210,7 @@
{categoryStats}
{pieChartData}
totalExpense={totalStats.expense}
bind:records
records={analysisRecords}
categories={sortedCategories()}
/>
@@ -161,7 +220,30 @@
<!-- Top 10 支出 -->
<TopExpenses records={topExpenses} categories={sortedCategories()} />
{:else if !isLoading}
<EmptyState onLoadDemo={loadDemoData} />
{: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>