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

@@ -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>