From b226c85fa747ee0c511be9cb3a9eb67da7ac4ec3 Mon Sep 17 00:00:00 2001 From: CHE LIANG ZHAO Date: Thu, 8 Jan 2026 10:48:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=E6=B7=BB=E5=8A=A0=E8=B4=A6?= =?UTF-8?q?=E5=8D=95=E8=AF=A6=E6=83=85=E6=9F=A5=E7=9C=8B=E5=92=8C=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BillRecordsTable: 新增点击行查看详情弹窗,支持编辑模式 - CategoryRanking: 分类支出表格支持点击查看/编辑账单详情 - DailyTrendChart: 每日趋势表格支持点击查看/编辑账单详情 - TopExpenses: Top10支出支持点击查看/编辑,前三名高亮显示 - OverviewCards/MonthlyTrend: 添加卡片hover效果 - 新增 categories.ts: 集中管理账单分类数据 - 分类下拉按使用频率排序 --- .../analysis/BillRecordsTable.svelte | 483 ++++++++++++++++++ .../analysis/CategoryRanking.svelte | 200 +------- .../analysis/DailyTrendChart.svelte | 270 ++++------ .../components/analysis/MonthlyTrend.svelte | 2 +- .../components/analysis/OverviewCards.svelte | 6 +- .../components/analysis/TopExpenses.svelte | 258 +++++++++- web/src/lib/components/analysis/index.ts | 1 + web/src/lib/data/categories.ts | 53 ++ web/src/lib/data/index.ts | 8 + web/src/routes/analysis/+page.svelte | 28 +- 10 files changed, 922 insertions(+), 387 deletions(-) create mode 100644 web/src/lib/components/analysis/BillRecordsTable.svelte create mode 100644 web/src/lib/data/categories.ts create mode 100644 web/src/lib/data/index.ts diff --git a/web/src/lib/components/analysis/BillRecordsTable.svelte b/web/src/lib/components/analysis/BillRecordsTable.svelte new file mode 100644 index 0000000..812104a --- /dev/null +++ b/web/src/lib/components/analysis/BillRecordsTable.svelte @@ -0,0 +1,483 @@ + + +{#if records.length > 0} + + + + + + + {#if showCategory} + + + + {/if} + + + + {#if showDescription} + + + + {/if} + + + + + + + {#each paginatedRecords as record, i} + openDetail(record, (currentPage - 1) * pageSize + i)} + > + + {record.time.substring(0, 16)} + + {#if showCategory} + {record.category} + {/if} + {record.merchant} + {#if showDescription} + + {record.description || '-'} + + {/if} + + ¥{record.amount} + + + {/each} + + + + + {#if totalPages > 1} +
+
+ 显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRecords.length)} 条,共 {sortedRecords.length} 条 +
+
+ + + {#each Array.from({ length: totalPages }, (_, i) => i + 1) as page} + {#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)} + + {:else if page === currentPage - 2 || page === currentPage + 2} + ... + {/if} + {/each} + + +
+
+ {/if} +{:else} +
+ 暂无记录 +
+{/if} + + + + + + + + {isEditing ? '编辑账单' : '账单详情'} + + + {isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'} + + + + {#if selectedRecord} + {#if isEditing} + +
+
+ +
+ ¥ + +
+
+ +
+ + +
+ +
+ + {#if categories.length > 0} + + + {editForm.category || '选择分类'} + + + + {#each categories as category} + {category} + {/each} + + + + {:else} + + {/if} +
+ +
+ + +
+ +
+ + +
+
+ {:else} + +
+
+
+ ¥{selectedRecord.amount} +
+
+ 支出金额 +
+
+ +
+
+ +
+
商家
+
{selectedRecord.merchant}
+
+
+ +
+ +
+
分类
+
{selectedRecord.category}
+
+
+ +
+ +
+
时间
+
{selectedRecord.time}
+
+
+ + {#if selectedRecord.description} +
+ +
+
描述
+
{selectedRecord.description}
+
+
+ {/if} + + {#if selectedRecord.payment_method} +
+ +
+
支付方式
+
{selectedRecord.payment_method}
+
+
+ {/if} +
+
+ {/if} + {/if} + + + {#if isEditing} + + + {:else} + + + {/if} + +
+
diff --git a/web/src/lib/components/analysis/CategoryRanking.svelte b/web/src/lib/components/analysis/CategoryRanking.svelte index e0e4062..b6980f2 100644 --- a/web/src/lib/components/analysis/CategoryRanking.svelte +++ b/web/src/lib/components/analysis/CategoryRanking.svelte @@ -1,29 +1,24 @@ - +
@@ -167,7 +107,7 @@
{#each expenseStats as stat, i} - - - - - - - - - - - - - - {#each paginatedRecords as record} - - - {record.time.substring(0, 16)} - - {record.merchant} - - {record.description || '-'} - - - ¥{record.amount} - - - {/each} - - - - - {#if totalPages > 1} -
-
- 显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, selectedRecords.length)} 条,共 {selectedRecords.length} 条 -
-
- - - {#each Array.from({ length: totalPages }, (_, i) => i + 1) as page} - {#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)} - - {:else if page === currentPage - 2 || page === currentPage + 2} - ... - {/if} - {/each} - - -
-
- {/if} - {:else} -
- 暂无记录 -
- {/if} +
diff --git a/web/src/lib/components/analysis/DailyTrendChart.svelte b/web/src/lib/components/analysis/DailyTrendChart.svelte index c91b44d..e38a51a 100644 --- a/web/src/lib/components/analysis/DailyTrendChart.svelte +++ b/web/src/lib/components/analysis/DailyTrendChart.svelte @@ -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(null); let selectedDateRecords = $state([]); - - // 排序状态 - type SortField = 'time' | 'category' | 'merchant' | 'amount'; - type SortOrder = 'asc' | 'desc'; - let sortField = $state('amount'); - let sortOrder = $state('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()} - +
@@ -669,116 +677,14 @@
-

详细记录(点击表头排序)

-
- -
- - - - -
- - {#each paginatedRecords as record} -
-
- {record.time.split(' ')[1] || '--:--'} -
-
{record.category}
-
- {record.merchant} -
-
- ¥{record.amount} -
-
- {/each} -
- - - {#if totalPages > 1} -
-
- 显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedDateRecords.length)} 条,共 {sortedDateRecords.length} 条 -
-
- - - {#each Array.from({ length: totalPages }, (_, i) => i + 1) as page} - {#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)} - - {:else if page === currentPage - 2 || page === currentPage + 2} - ... - {/if} - {/each} - - -
-
- {/if} +

详细记录

+
{:else}

暂无数据

diff --git a/web/src/lib/components/analysis/MonthlyTrend.svelte b/web/src/lib/components/analysis/MonthlyTrend.svelte index 793d3aa..01864b5 100644 --- a/web/src/lib/components/analysis/MonthlyTrend.svelte +++ b/web/src/lib/components/analysis/MonthlyTrend.svelte @@ -13,7 +13,7 @@ let maxValue = $derived(Math.max(...monthlyStats.map(s => Math.max(s.expense, s.income)))); - + diff --git a/web/src/lib/components/analysis/OverviewCards.svelte b/web/src/lib/components/analysis/OverviewCards.svelte index dc35c20..02e18ef 100644 --- a/web/src/lib/components/analysis/OverviewCards.svelte +++ b/web/src/lib/components/analysis/OverviewCards.svelte @@ -20,7 +20,7 @@
- + 总支出 @@ -33,7 +33,7 @@ - + 总收入 @@ -46,7 +46,7 @@ - = 0 ? 'border-blue-200 dark:border-blue-900' : 'border-orange-200 dark:border-orange-900'}> + 结余 diff --git a/web/src/lib/components/analysis/TopExpenses.svelte b/web/src/lib/components/analysis/TopExpenses.svelte index 6ff0cba..c330432 100644 --- a/web/src/lib/components/analysis/TopExpenses.svelte +++ b/web/src/lib/components/analysis/TopExpenses.svelte @@ -1,28 +1,121 @@ - + Top 10 单笔支出 - 最大的单笔支出记录 + 最大的单笔支出记录(点击查看详情)
{#each records as record, i} -
-
+ {/each}
+ + + + + + + {isEditing ? '编辑账单' : '账单详情'} + {#if selectedRank <= 3 && !isEditing} + + Top {selectedRank} + + {/if} + + + {isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'} + + + + {#if selectedRecord} + {#if isEditing} + +
+
+ +
+ ¥ + +
+
+
+ + +
+ +
+ + + + {editForm.category || '选择分类'} + + + + {#each categories as category} + {category} + {/each} + + + +
+ +
+ + +
+ +
+ + +
+
+ {:else} + +
+ +
+

支出金额

+

+ ¥{selectedRecord.amount} +

+
+ + +
+
+ +
+

商家

+

{selectedRecord.merchant}

+
+
+ +
+ +
+

分类

+

{selectedRecord.category}

+
+
+ +
+ +
+

时间

+

{selectedRecord.time}

+
+
+ + {#if selectedRecord.description} +
+ +
+

描述

+

{selectedRecord.description}

+
+
+ {/if} + + {#if selectedRecord.payment_method} +
+ +
+

支付方式

+

{selectedRecord.payment_method}

+
+
+ {/if} +
+
+ {/if} + {/if} + + + {#if isEditing} + + + {:else} + + + {/if} + +
+
diff --git a/web/src/lib/components/analysis/index.ts b/web/src/lib/components/analysis/index.ts index a6f939c..0571c9f 100644 --- a/web/src/lib/components/analysis/index.ts +++ b/web/src/lib/components/analysis/index.ts @@ -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'; diff --git a/web/src/lib/data/categories.ts b/web/src/lib/data/categories.ts new file mode 100644 index 0000000..8a09680 --- /dev/null +++ b/web/src/lib/data/categories.ts @@ -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; + diff --git a/web/src/lib/data/index.ts b/web/src/lib/data/index.ts new file mode 100644 index 0000000..6fd0ab3 --- /dev/null +++ b/web/src/lib/data/index.ts @@ -0,0 +1,8 @@ +export { demoRecords } from './demo'; +export { + categories, + expenseCategories, + incomeCategories, + type Category +} from './categories'; + diff --git a/web/src/routes/analysis/+page.svelte b/web/src/routes/analysis/+page.svelte index c5d3724..d3e31f4 100644 --- a/web/src/routes/analysis/+page.svelte +++ b/web/src/routes/analysis/+page.svelte @@ -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(); + 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 @@ - +
@@ -130,7 +151,8 @@ {categoryStats} {pieChartData} totalExpense={totalStats.expense} - {records} + bind:records + categories={sortedCategories()} /> @@ -138,7 +160,7 @@
- + {:else if !isLoading} {/if}