feat(analysis): 添加账单详情查看和编辑功能
- BillRecordsTable: 新增点击行查看详情弹窗,支持编辑模式 - CategoryRanking: 分类支出表格支持点击查看/编辑账单详情 - DailyTrendChart: 每日趋势表格支持点击查看/编辑账单详情 - TopExpenses: Top10支出支持点击查看/编辑,前三名高亮显示 - OverviewCards/MonthlyTrend: 添加卡片hover效果 - 新增 categories.ts: 集中管理账单分类数据 - 分类下拉按使用频率排序
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user