feat(analysis): 添加账单详情查看和编辑功能

- BillRecordsTable: 新增点击行查看详情弹窗,支持编辑模式
- CategoryRanking: 分类支出表格支持点击查看/编辑账单详情
- DailyTrendChart: 每日趋势表格支持点击查看/编辑账单详情
- TopExpenses: Top10支出支持点击查看/编辑,前三名高亮显示
- OverviewCards/MonthlyTrend: 添加卡片hover效果
- 新增 categories.ts: 集中管理账单分类数据
- 分类下拉按使用频率排序
This commit is contained in:
CHE LIANG ZHAO
2026-01-08 10:48:11 +08:00
parent 9d409d6a93
commit b226c85fa7
10 changed files with 922 additions and 387 deletions

View File

@@ -0,0 +1,483 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Receipt from '@lucide/svelte/icons/receipt';
import Pencil from '@lucide/svelte/icons/pencil';
import Save from '@lucide/svelte/icons/save';
import X from '@lucide/svelte/icons/x';
import Calendar from '@lucide/svelte/icons/calendar';
import Store from '@lucide/svelte/icons/store';
import Tag from '@lucide/svelte/icons/tag';
import FileText from '@lucide/svelte/icons/file-text';
import CreditCard from '@lucide/svelte/icons/credit-card';
import type { BillRecord } from '$lib/api';
interface Props {
records: BillRecord[];
showCategory?: boolean;
showDescription?: boolean;
pageSize?: number;
categories?: string[];
onUpdate?: (updated: BillRecord, original: BillRecord) => void;
}
let {
records = $bindable(),
showCategory = false,
showDescription = true,
pageSize = 10,
categories = [],
onUpdate
}: Props = $props();
// 排序状态
type SortField = 'time' | 'category' | 'merchant' | 'description' | 'amount';
type SortOrder = 'asc' | 'desc';
let sortField = $state<SortField>('time');
let sortOrder = $state<SortOrder>('desc');
// 分页状态
let currentPage = $state(1);
// 详情弹窗状态
let detailDialogOpen = $state(false);
let selectedRecord = $state<BillRecord | null>(null);
let selectedIndex = $state(-1);
let isEditing = $state(false);
let editForm = $state({
amount: '',
merchant: '',
category: '',
description: '',
payment_method: ''
});
// 排序后的记录
let sortedRecords = $derived.by(() => {
return records.toSorted((a, b) => {
let cmp = 0;
switch (sortField) {
case 'time':
cmp = new Date(a.time).getTime() - new Date(b.time).getTime();
break;
case 'category':
cmp = a.category.localeCompare(b.category);
break;
case 'merchant':
cmp = a.merchant.localeCompare(b.merchant);
break;
case 'description':
cmp = (a.description || '').localeCompare(b.description || '');
break;
case 'amount':
cmp = parseFloat(a.amount) - parseFloat(b.amount);
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
});
// 分页计算
let totalPages = $derived(Math.ceil(sortedRecords.length / pageSize));
let paginatedRecords = $derived(
sortedRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
function toggleSort(field: SortField) {
if (sortField === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortOrder = field === 'amount' ? 'desc' : 'asc';
}
currentPage = 1;
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
}
}
// 打开详情弹窗
function openDetail(record: BillRecord, index: number) {
selectedRecord = record;
selectedIndex = index;
isEditing = false;
detailDialogOpen = true;
}
// 进入编辑模式
function startEdit() {
if (!selectedRecord) return;
editForm = {
amount: selectedRecord.amount,
merchant: selectedRecord.merchant,
category: selectedRecord.category,
description: selectedRecord.description || '',
payment_method: selectedRecord.payment_method || ''
};
isEditing = true;
}
// 取消编辑
function cancelEdit() {
isEditing = false;
}
// 保存编辑
function saveEdit() {
if (!selectedRecord) return;
const original = { ...selectedRecord };
const updated: BillRecord = {
...selectedRecord,
amount: editForm.amount,
merchant: editForm.merchant,
category: editForm.category,
description: editForm.description,
payment_method: editForm.payment_method
};
// 更新本地数据
const idx = records.findIndex(r =>
r.time === selectedRecord!.time &&
r.merchant === selectedRecord!.merchant &&
r.amount === selectedRecord!.amount
);
if (idx !== -1) {
records[idx] = updated;
records = [...records]; // 触发响应式更新
}
selectedRecord = updated;
isEditing = false;
// 通知父组件
onUpdate?.(updated, original);
}
// 处理分类选择
function handleCategoryChange(value: string | undefined) {
if (value) {
editForm.category = value;
}
}
// 重置分页(当记录变化时)
$effect(() => {
records;
currentPage = 1;
sortField = 'time';
sortOrder = 'desc';
});
</script>
{#if records.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[140px]">
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('time')}
>
时间
{#if sortField === 'time'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{#if showCategory}
<Table.Head class="w-[100px]">
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('category')}
>
分类
{#if sortField === 'category'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{/if}
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('merchant')}
>
商家
{#if sortField === 'merchant'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{#if showDescription}
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('description')}
>
描述
{#if sortField === 'description'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{/if}
<Table.Head class="text-right w-[100px]">
<button
class="flex items-center gap-1 ml-auto hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('amount')}
>
金额
{#if sortField === 'amount'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRecords as record, i}
<Table.Row
class="hover:bg-muted/50 transition-colors cursor-pointer"
onclick={() => openDetail(record, (currentPage - 1) * pageSize + i)}
>
<Table.Cell class="text-muted-foreground text-xs">
{record.time.substring(0, 16)}
</Table.Cell>
{#if showCategory}
<Table.Cell class="text-sm">{record.category}</Table.Cell>
{/if}
<Table.Cell class="font-medium">{record.merchant}</Table.Cell>
{#if showDescription}
<Table.Cell class="text-muted-foreground truncate max-w-[200px]">
{record.description || '-'}
</Table.Cell>
{/if}
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
¥{record.amount}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<!-- 分页控件 -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-4 pt-4 border-t">
<div class="text-sm text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRecords.length)} 条,共 {sortedRecords.length}
</div>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-4 w-4" />
</Button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => goToPage(page)}
>
{page}
</Button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-muted-foreground">...</span>
{/if}
{/each}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === totalPages}
onclick={() => goToPage(currentPage + 1)}
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
{/if}
{:else}
<div class="text-center py-8 text-muted-foreground">
暂无记录
</div>
{/if}
<!-- 详情/编辑弹窗 -->
<Dialog.Root bind:open={detailDialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" />
{isEditing ? '编辑账单' : '账单详情'}
</Dialog.Title>
<Dialog.Description>
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
</Dialog.Description>
</Dialog.Header>
{#if selectedRecord}
{#if isEditing}
<!-- 编辑表单 -->
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label>金额</Label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
<Input
type="number"
bind:value={editForm.amount}
class="pl-8"
step="0.01"
/>
</div>
</div>
<div class="space-y-2">
<Label>商家</Label>
<Input bind:value={editForm.merchant} />
</div>
<div class="space-y-2">
<Label>分类</Label>
{#if categories.length > 0}
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
<Select.Trigger class="w-full">
<span>{editForm.category || '选择分类'}</span>
</Select.Trigger>
<Select.Portal>
<Select.Content>
{#each categories as category}
<Select.Item value={category}>{category}</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<Input bind:value={editForm.category} />
{/if}
</div>
<div class="space-y-2">
<Label>描述</Label>
<Input bind:value={editForm.description} />
</div>
<div class="space-y-2">
<Label>支付方式</Label>
<Input bind:value={editForm.payment_method} />
</div>
</div>
{:else}
<!-- 详情展示 -->
<div class="py-4">
<div class="text-center mb-6">
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
¥{selectedRecord.amount}
</div>
<div class="text-sm text-muted-foreground mt-1">
支出金额
</div>
</div>
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Store class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">商家</div>
<div class="font-medium truncate">{selectedRecord.merchant}</div>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Tag class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">分类</div>
<div class="font-medium">{selectedRecord.category}</div>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Calendar class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">时间</div>
<div class="font-medium">{selectedRecord.time}</div>
</div>
</div>
{#if selectedRecord.description}
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<FileText class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">描述</div>
<div class="font-medium">{selectedRecord.description}</div>
</div>
</div>
{/if}
{#if selectedRecord.payment_method}
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<CreditCard class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">支付方式</div>
<div class="font-medium">{selectedRecord.payment_method}</div>
</div>
</div>
{/if}
</div>
</div>
{/if}
{/if}
<Dialog.Footer>
{#if isEditing}
<Button variant="outline" onclick={cancelEdit}>
<X class="h-4 w-4 mr-2" />
取消
</Button>
<Button onclick={saveEdit}>
<Save class="h-4 w-4 mr-2" />
保存
</Button>
{:else}
<Button variant="outline" onclick={() => detailDialogOpen = false}>
关闭
</Button>
<Button onclick={startEdit}>
<Pencil class="h-4 w-4 mr-2" />
编辑
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,29 +1,24 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
import ListIcon from '@lucide/svelte/icons/list';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import X from '@lucide/svelte/icons/x';
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
import type { BillRecord } from '$lib/api';
import { getPercentage } from '$lib/services/analysis';
import { barColors } from '$lib/constants/chart';
import BillRecordsTable from './BillRecordsTable.svelte';
interface Props {
categoryStats: CategoryStat[];
pieChartData: PieChartDataItem[];
totalExpense: number;
records: BillRecord[];
categories?: string[];
}
let { categoryStats, pieChartData, totalExpense, records }: Props = $props();
let { categoryStats, pieChartData, totalExpense, records = $bindable(), categories = [] }: Props = $props();
let mode = $state<'bar' | 'pie'>('bar');
let dialogOpen = $state(false);
@@ -58,50 +53,14 @@
let filteredTotalExpense = $derived(
filteredPieChartData.reduce((sum, item) => sum + item.value, 0)
);
// 排序状态
type SortField = 'time' | 'merchant' | 'description' | 'amount';
type SortOrder = 'asc' | 'desc';
let sortField = $state<SortField>('time');
let sortOrder = $state<SortOrder>('desc');
// 分页状态
let currentPage = $state(1);
const pageSize = 10; // 每页条目数
let expenseStats = $derived(categoryStats.filter(s => s.expense > 0));
// 获取选中分类的账单记录(已排序)
// 获取选中分类的账单记录
let selectedRecords = $derived.by(() => {
if (!selectedCategory) return [];
const filtered = records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
return filtered.toSorted((a, b) => {
let cmp = 0;
switch (sortField) {
case 'time':
cmp = new Date(a.time).getTime() - new Date(b.time).getTime();
break;
case 'merchant':
cmp = a.merchant.localeCompare(b.merchant);
break;
case 'description':
cmp = (a.description || '').localeCompare(b.description || '');
break;
case 'amount':
cmp = parseFloat(a.amount) - parseFloat(b.amount);
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
return records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
});
// 分页计算
let totalPages = $derived(Math.ceil(selectedRecords.length / pageSize));
let paginatedRecords = $derived(
selectedRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
// 选中分类的统计
let selectedStat = $derived(
@@ -110,30 +69,11 @@
function openCategoryDetail(category: string) {
selectedCategory = category;
sortField = 'time';
sortOrder = 'desc';
currentPage = 1;
dialogOpen = true;
}
function toggleSort(field: SortField) {
if (sortField === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortOrder = field === 'amount' ? 'desc' : 'asc';
}
currentPage = 1; // 排序后重置到第一页
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
}
}
</script>
<Card.Root>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div class="space-y-1.5">
<Card.Title class="flex items-center gap-2">
@@ -167,7 +107,7 @@
<div class="space-y-1">
{#each expenseStats as stat, i}
<button
class="w-full text-left space-y-1 p-2 rounded-lg hover:bg-accent/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 cursor-pointer outline-none focus:outline-none"
class="w-full text-left space-y-1 p-2 rounded-lg bg-muted/30 hover:bg-muted hover:shadow-sm hover:scale-[1.02] transition-all duration-150 cursor-pointer outline-none focus:outline-none"
onclick={() => openCategoryDetail(stat.category)}
>
<div class="flex items-center justify-between text-sm">
@@ -272,131 +212,7 @@
</Dialog.Header>
<div class="flex-1 overflow-auto mt-4">
{#if selectedRecords.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[140px]">
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('time')}
>
时间
{#if sortField === 'time'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('merchant')}
>
商家
{#if sortField === 'merchant'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('description')}
>
描述
{#if sortField === 'description'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
<Table.Head class="text-right w-[100px]">
<button
class="flex items-center gap-1 ml-auto hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('amount')}
>
金额
{#if sortField === 'amount'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRecords as record}
<Table.Row>
<Table.Cell class="text-muted-foreground text-xs">
{record.time.substring(0, 16)}
</Table.Cell>
<Table.Cell class="font-medium">{record.merchant}</Table.Cell>
<Table.Cell class="text-muted-foreground truncate max-w-[200px]">
{record.description || '-'}
</Table.Cell>
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
¥{record.amount}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<!-- 分页控件 -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-4 pt-4 border-t">
<div class="text-sm text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, selectedRecords.length)} 条,共 {selectedRecords.length}
</div>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-4 w-4" />
</Button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => goToPage(page)}
>
{page}
</Button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-muted-foreground">...</span>
{/if}
{/each}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === totalPages}
onclick={() => goToPage(currentPage + 1)}
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
{/if}
{:else}
<div class="text-center py-8 text-muted-foreground">
暂无记录
</div>
{/if}
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} />
</div>
<Dialog.Footer class="mt-4">

View File

@@ -6,79 +6,22 @@
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import Calendar from '@lucide/svelte/icons/calendar';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { Button } from '$lib/components/ui/button';
import type { BillRecord } from '$lib/api';
import { pieColors } from '$lib/constants/chart';
import BillRecordsTable from './BillRecordsTable.svelte';
interface Props {
records: BillRecord[];
categories?: string[];
}
let { records }: Props = $props();
let { records = $bindable(), categories = [] }: Props = $props();
// Dialog 状态
let dialogOpen = $state(false);
let selectedDate = $state<Date | null>(null);
let selectedDateRecords = $state<BillRecord[]>([]);
// 排序状态
type SortField = 'time' | 'category' | 'merchant' | 'amount';
type SortOrder = 'asc' | 'desc';
let sortField = $state<SortField>('amount');
let sortOrder = $state<SortOrder>('desc');
// 分页状态
let currentPage = $state(1);
const pageSize = 8; // 每页条目数
// 排序后的记录
let sortedDateRecords = $derived.by(() => {
return selectedDateRecords.toSorted((a, b) => {
let cmp = 0;
switch (sortField) {
case 'time':
cmp = a.time.localeCompare(b.time);
break;
case 'category':
cmp = a.category.localeCompare(b.category);
break;
case 'merchant':
cmp = a.merchant.localeCompare(b.merchant);
break;
case 'amount':
cmp = parseFloat(a.amount) - parseFloat(b.amount);
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
});
// 分页计算
let totalPages = $derived(Math.ceil(sortedDateRecords.length / pageSize));
let paginatedRecords = $derived(
sortedDateRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
function toggleSort(field: SortField) {
if (sortField === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortOrder = field === 'amount' ? 'desc' : 'asc';
}
currentPage = 1; // 排序后重置到第一页
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
}
}
// 时间范围选项
type TimeRange = '7d' | '30d' | '3m';
@@ -265,22 +208,91 @@
return padding.top + innerHeight - (value / maxValue) * innerHeight;
}
// 生成面积路径
// 生成平滑曲线的控制点 (Catmull-Rom to Bezier)
function getCurveControlPoints(
p0: { x: number; y: number },
p1: { x: number; y: number },
p2: { x: number; y: number },
p3: { x: number; y: number },
tension: number = 0.3
): { cp1: { x: number; y: number }; cp2: { x: number; y: number } } {
const d1 = Math.sqrt(Math.pow(p1.x - p0.x, 2) + Math.pow(p1.y - p0.y, 2));
const d2 = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
const d3 = Math.sqrt(Math.pow(p3.x - p2.x, 2) + Math.pow(p3.y - p2.y, 2));
const d1a = Math.pow(d1, tension);
const d2a = Math.pow(d2, tension);
const d3a = Math.pow(d3, tension);
const cp1 = {
x: p1.x + (d1a !== 0 ? (p2.x - p0.x) * d2a / (d1a + d2a) / 6 * tension * 6 : 0),
y: p1.y + (d1a !== 0 ? (p2.y - p0.y) * d2a / (d1a + d2a) / 6 * tension * 6 : 0)
};
const cp2 = {
x: p2.x - (d3a !== 0 ? (p3.x - p1.x) * d2a / (d2a + d3a) / 6 * tension * 6 : 0),
y: p2.y - (d3a !== 0 ? (p3.y - p1.y) * d2a / (d2a + d3a) / 6 * tension * 6 : 0)
};
return { cp1, cp2 };
}
// 生成平滑曲线路径
function generateSmoothPath(points: { x: number; y: number }[]): string {
if (points.length < 2) return '';
if (points.length === 2) {
return `L ${points[1].x},${points[1].y}`;
}
let path = '';
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
const { cp1, cp2 } = getCurveControlPoints(p0, p1, p2, p3);
path += ` C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${p2.x},${p2.y}`;
}
return path;
}
// 生成面积路径(平滑曲线版本)
function generateAreaPath(category: string, data: any[], maxValue: number): string {
if (data.length === 0) return '';
const points: string[] = [];
const bottomPoints: string[] = [];
const topPoints: { x: number; y: number }[] = [];
const bottomPoints: { x: number; y: number }[] = [];
data.forEach((d, i) => {
data.forEach((d) => {
const x = xScale(d.date, data);
const y1 = yScale(d[`${category}_y1`] || 0, maxValue);
const y0 = yScale(d[`${category}_y0`] || 0, maxValue);
points.push(`${x},${y1}`);
bottomPoints.unshift(`${x},${y0}`);
topPoints.push({ x, y: y1 });
bottomPoints.unshift({ x, y: y0 });
});
return `M ${points.join(' L ')} L ${bottomPoints.join(' L ')} Z`;
// 起始点
const startPoint = topPoints[0];
let path = `M ${startPoint.x},${startPoint.y}`;
// 顶部曲线
path += generateSmoothPath(topPoints);
// 连接到底部起点
const bottomStart = bottomPoints[0];
path += ` L ${bottomStart.x},${bottomStart.y}`;
// 底部曲线
path += generateSmoothPath(bottomPoints);
// 闭合路径
path += ' Z';
return path;
}
// 生成 X 轴刻度
@@ -381,10 +393,6 @@
return recordDateStr === dateStr;
});
// 重置排序和分页状态
sortField = 'amount';
sortOrder = 'desc';
currentPage = 1;
dialogOpen = true;
}
@@ -418,7 +426,7 @@
{#if processedData().data.length > 1}
{@const { data, categories, maxValue } = processedData()}
<Card.Root>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div class="space-y-1.5">
<Card.Title class="flex items-center gap-2">
@@ -669,116 +677,14 @@
<!-- 详细记录 -->
<div>
<h4 class="text-sm font-medium text-muted-foreground mb-2">详细记录(点击表头排序)</h4>
<div class="rounded-md border overflow-auto max-h-[300px]">
<!-- 表头 -->
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b text-xs font-medium text-muted-foreground sticky top-0">
<button
class="w-[60px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('time')}
>
时间
{#if sortField === 'time'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
<button
class="w-[80px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('category')}
>
分类
{#if sortField === 'category'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
<button
class="flex-1 min-w-[100px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('merchant')}
>
商家
{#if sortField === 'merchant'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
<button
class="w-[100px] flex items-center justify-end gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('amount')}
>
金额
{#if sortField === 'amount'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</div>
<!-- 记录列表 -->
{#each paginatedRecords as record}
<div class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-muted/30">
<div class="w-[60px] text-xs text-muted-foreground">
{record.time.split(' ')[1] || '--:--'}
</div>
<div class="w-[80px] text-sm">{record.category}</div>
<div class="flex-1 min-w-[100px] text-sm truncate" title="{record.merchant}">
{record.merchant}
</div>
<div class="w-[100px] text-right font-mono text-sm font-medium text-red-600 dark:text-red-400">
¥{record.amount}
</div>
</div>
{/each}
</div>
<!-- 分页控件 -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-3 pt-3 border-t">
<div class="text-xs text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedDateRecords.length)} 条,共 {sortedDateRecords.length}
</div>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="icon"
class="h-7 w-7"
disabled={currentPage === 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="icon"
class="h-7 w-7 text-xs"
onclick={() => goToPage(page)}
>
{page}
</Button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-muted-foreground text-xs">...</span>
{/if}
{/each}
<Button
variant="outline"
size="icon"
class="h-7 w-7"
disabled={currentPage === totalPages}
onclick={() => goToPage(currentPage + 1)}
>
<ChevronRight class="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/if}
<h4 class="text-sm font-medium text-muted-foreground mb-2">详细记录</h4>
<BillRecordsTable
bind:records={selectedDateRecords}
showCategory={true}
showDescription={false}
pageSize={8}
{categories}
/>
</div>
{:else}
<p class="text-center text-muted-foreground py-8">暂无数据</p>

View File

@@ -13,7 +13,7 @@
let maxValue = $derived(Math.max(...monthlyStats.map(s => Math.max(s.expense, s.income))));
</script>
<Card.Root>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<BarChart3 class="h-5 w-5" />

View File

@@ -20,7 +20,7 @@
</script>
<div class="grid gap-4 md:grid-cols-3">
<Card.Root class="border-red-200 dark:border-red-900">
<Card.Root class="border-red-200 dark:border-red-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-red-300 dark:hover:border-red-800 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总支出</Card.Title>
<TrendingDown class="h-4 w-4 text-red-500" />
@@ -33,7 +33,7 @@
</Card.Content>
</Card.Root>
<Card.Root class="border-green-200 dark:border-green-900">
<Card.Root class="border-green-200 dark:border-green-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-green-300 dark:hover:border-green-800 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总收入</Card.Title>
<TrendingUp class="h-4 w-4 text-green-500" />
@@ -46,7 +46,7 @@
</Card.Content>
</Card.Root>
<Card.Root class={balance >= 0 ? 'border-blue-200 dark:border-blue-900' : 'border-orange-200 dark:border-orange-900'}>
<Card.Root class="{balance >= 0 ? 'border-blue-200 dark:border-blue-900 hover:border-blue-300 dark:hover:border-blue-800' : 'border-orange-200 dark:border-orange-900 hover:border-orange-300 dark:hover:border-orange-800'} transition-all duration-200 hover:shadow-lg hover:-translate-y-1 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">结余</Card.Title>
<Wallet class="h-4 w-4 {balance >= 0 ? 'text-blue-500' : 'text-orange-500'}" />

View File

@@ -1,28 +1,121 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import Flame from '@lucide/svelte/icons/flame';
import Receipt from '@lucide/svelte/icons/receipt';
import Pencil from '@lucide/svelte/icons/pencil';
import Save from '@lucide/svelte/icons/save';
import X from '@lucide/svelte/icons/x';
import Calendar from '@lucide/svelte/icons/calendar';
import Store from '@lucide/svelte/icons/store';
import Tag from '@lucide/svelte/icons/tag';
import FileText from '@lucide/svelte/icons/file-text';
import CreditCard from '@lucide/svelte/icons/credit-card';
import type { BillRecord } from '$lib/api';
interface Props {
records: BillRecord[];
categories: string[]; // 可用的分类列表
onUpdate?: (record: BillRecord) => void;
}
let { records }: Props = $props();
let { records, categories, onUpdate }: Props = $props();
let dialogOpen = $state(false);
let selectedRecord = $state<BillRecord | null>(null);
let selectedRank = $state(0);
let isEditing = $state(false);
// 编辑表单数据
let editForm = $state({
merchant: '',
category: '',
amount: '',
description: '',
payment_method: ''
});
function openDetail(record: BillRecord, rank: number) {
selectedRecord = record;
selectedRank = rank;
isEditing = false;
dialogOpen = true;
}
function startEdit() {
if (!selectedRecord) return;
editForm = {
merchant: selectedRecord.merchant,
category: selectedRecord.category,
amount: selectedRecord.amount,
description: selectedRecord.description || '',
payment_method: selectedRecord.payment_method || ''
};
isEditing = true;
}
function cancelEdit() {
isEditing = false;
}
function saveEdit() {
if (!selectedRecord) return;
// 更新记录
const updatedRecord: BillRecord = {
...selectedRecord,
merchant: editForm.merchant,
category: editForm.category,
amount: editForm.amount,
description: editForm.description,
payment_method: editForm.payment_method
};
// 更新本地数据
const index = records.findIndex(r => r === selectedRecord);
if (index !== -1) {
records[index] = updatedRecord;
}
selectedRecord = updatedRecord;
isEditing = false;
// 通知父组件
onUpdate?.(updatedRecord);
}
function handleCategoryChange(value: string | undefined) {
if (value) {
editForm.category = value;
}
}
</script>
<Card.Root>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Flame class="h-5 w-5 text-orange-500" />
Top 10 单笔支出
</Card.Title>
<Card.Description>最大的单笔支出记录</Card.Description>
<Card.Description>最大的单笔支出记录(点击查看详情)</Card.Description>
</Card.Header>
<Card.Content>
<div class="space-y-3">
{#each records as record, i}
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-sm">
<button
class="w-full flex items-center gap-4 p-3 rounded-lg bg-muted/50 transition-all duration-150 hover:bg-muted hover:scale-[1.02] hover:shadow-sm cursor-pointer text-left outline-none focus:outline-none"
onclick={() => openDetail(record, i + 1)}
>
<div class="flex h-8 w-8 items-center justify-center rounded-full font-bold text-sm {
i === 0 ? 'bg-gradient-to-br from-yellow-400 to-amber-500 text-white shadow-md' :
i === 1 ? 'bg-gradient-to-br from-slate-300 to-slate-400 text-white shadow-md' :
i === 2 ? 'bg-gradient-to-br from-orange-400 to-amber-600 text-white shadow-md' :
'bg-primary/10 text-primary'
}">
{i + 1}
</div>
<div class="flex-1 min-w-0">
@@ -34,10 +127,163 @@
<div class="font-mono font-bold text-red-600 dark:text-red-400">
¥{record.amount}
</div>
</div>
</button>
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- 账单详情弹窗 -->
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="sm:max-w-[450px]">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" />
{isEditing ? '编辑账单' : '账单详情'}
{#if selectedRank <= 3 && !isEditing}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {
selectedRank === 1 ? 'bg-gradient-to-r from-yellow-400 to-amber-500 text-white' :
selectedRank === 2 ? 'bg-gradient-to-r from-slate-300 to-slate-400 text-white' :
'bg-gradient-to-r from-orange-400 to-amber-600 text-white'
}">
Top {selectedRank}
</span>
{/if}
</Dialog.Title>
<Dialog.Description>
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
</Dialog.Description>
</Dialog.Header>
{#if selectedRecord}
{#if isEditing}
<!-- 编辑模式 -->
<div class="py-4 space-y-4">
<div class="space-y-2">
<Label for="amount">金额</Label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
<Input
id="amount"
type="number"
step="0.01"
bind:value={editForm.amount}
class="pl-7 font-mono"
/>
</div>
</div>
<div class="space-y-2">
<Label for="merchant">商家</Label>
<Input id="merchant" bind:value={editForm.merchant} />
</div>
<div class="space-y-2">
<Label>分类</Label>
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
<Select.Trigger class="w-full">
<span>{editForm.category || '选择分类'}</span>
</Select.Trigger>
<Select.Portal>
<Select.Content>
{#each categories as category}
<Select.Item value={category}>{category}</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<div class="space-y-2">
<Label for="description">描述</Label>
<Input id="description" bind:value={editForm.description} placeholder="可选" />
</div>
<div class="space-y-2">
<Label for="payment_method">支付方式</Label>
<Input id="payment_method" bind:value={editForm.payment_method} placeholder="可选" />
</div>
</div>
{:else}
<!-- 查看模式 -->
<div class="py-4 space-y-4">
<!-- 金额 -->
<div class="text-center py-4 bg-red-50 dark:bg-red-950/30 rounded-lg">
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
<p class="text-3xl font-bold font-mono text-red-600 dark:text-red-400">
¥{selectedRecord.amount}
</p>
</div>
<!-- 详情列表 -->
<div class="space-y-3">
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<Store class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">商家</p>
<p class="font-medium">{selectedRecord.merchant}</p>
</div>
</div>
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<Tag class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">分类</p>
<p class="font-medium">{selectedRecord.category}</p>
</div>
</div>
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<Calendar class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">时间</p>
<p class="font-medium">{selectedRecord.time}</p>
</div>
</div>
{#if selectedRecord.description}
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<FileText class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">描述</p>
<p class="font-medium">{selectedRecord.description}</p>
</div>
</div>
{/if}
{#if selectedRecord.payment_method}
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<CreditCard class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">支付方式</p>
<p class="font-medium">{selectedRecord.payment_method}</p>
</div>
</div>
{/if}
</div>
</div>
{/if}
{/if}
<Dialog.Footer class="flex gap-2">
{#if isEditing}
<Button variant="outline" onclick={cancelEdit}>
<X class="h-4 w-4 mr-2" />
取消
</Button>
<Button onclick={saveEdit}>
<Save class="h-4 w-4 mr-2" />
保存
</Button>
{:else}
<Button variant="outline" onclick={() => dialogOpen = false}>
关闭
</Button>
<Button onclick={startEdit}>
<Pencil class="h-4 w-4 mr-2" />
编辑
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -4,5 +4,6 @@ export { default as CategoryRanking } from './CategoryRanking.svelte';
export { default as MonthlyTrend } from './MonthlyTrend.svelte';
export { default as TopExpenses } from './TopExpenses.svelte';
export { default as EmptyState } from './EmptyState.svelte';
export { default as BillRecordsTable } from './BillRecordsTable.svelte';

View File

@@ -0,0 +1,53 @@
/** 账单分类列表 */
export const categories = [
'餐饮美食',
'交通出行',
'日用百货',
'充值缴费',
'家居家装',
'运动健身',
'文化休闲',
'数码电器',
'医疗健康',
'教育培训',
'美容护理',
'服饰鞋包',
'宠物相关',
'住房物业',
'退款',
'工资收入',
'投资理财',
'其他收入',
'其他支出',
] as const;
/** 分类类型 */
export type Category = (typeof categories)[number];
/** 支出分类(用于分析页面筛选) */
export const expenseCategories = [
'餐饮美食',
'交通出行',
'日用百货',
'充值缴费',
'家居家装',
'运动健身',
'文化休闲',
'数码电器',
'医疗健康',
'教育培训',
'美容护理',
'服饰鞋包',
'宠物相关',
'住房物业',
'其他支出',
] as const;
/** 收入分类 */
export const incomeCategories = [
'退款',
'工资收入',
'投资理财',
'其他收入',
] as const;

View File

@@ -0,0 +1,8 @@
export { demoRecords } from './demo';
export {
categories,
expenseCategories,
incomeCategories,
type Category
} from './categories';

View File

@@ -29,6 +29,8 @@
// 演示数据
import { demoRecords } from '$lib/data/demo';
// 分类数据
import { categories as allCategories } from '$lib/data/categories';
// 状态
let fileName = $state('');
@@ -44,6 +46,25 @@
let totalStats = $derived(calculateTotalStats(records));
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
let topExpenses = $derived(getTopExpenses(records, 10));
// 分类列表按数据中出现次数排序(出现次数多的优先)
let sortedCategories = $derived(() => {
// 统计每个分类的记录数量
const categoryCounts = new Map<string, number>();
for (const record of records) {
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;
@@ -122,7 +143,7 @@
<OverviewCards {totalStats} {records} />
<!-- 每日支出趋势图(按分类堆叠) -->
<DailyTrendChart {records} />
<DailyTrendChart bind:records categories={sortedCategories()} />
<div class="grid gap-6 lg:grid-cols-2">
<!-- 分类支出排行 -->
@@ -130,7 +151,8 @@
{categoryStats}
{pieChartData}
totalExpense={totalStats.expense}
{records}
bind:records
categories={sortedCategories()}
/>
<!-- 月度趋势 -->
@@ -138,7 +160,7 @@
</div>
<!-- Top 10 支出 -->
<TopExpenses records={topExpenses} />
<TopExpenses records={topExpenses} categories={sortedCategories()} />
{:else if !isLoading}
<EmptyState onLoadDemo={loadDemoData} />
{/if}