diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 766f8f8..fea183f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,6 @@ import { browser } from '$app/environment'; import { auth } from '$lib/stores/auth'; +import type { UIBill } from '$lib/models/bill'; // API 配置 - 使用相对路径,由 SvelteKit 代理到后端 const API_BASE = ''; @@ -95,19 +96,6 @@ export interface MonthlyStatsResponse { data?: MonthlyStat[]; } -export interface BillRecord { - time: string; - category: string; - merchant: string; - description: string; - income_expense: string; - amount: string; - payment_method: string; - status: string; - remark: string; - needs_review: string; -} - // 上传账单 export async function uploadBill( file: File, @@ -165,7 +153,7 @@ export function getDownloadUrl(fileUrl: string): string { } // 解析账单内容(用于前端展示全部记录) -export async function fetchBillContent(fileName: string): Promise { +export async function fetchBillContent(fileName: string): Promise { const response = await apiFetch(`${API_BASE}/download/${fileName}`); if (!response.ok) { @@ -177,11 +165,11 @@ export async function fetchBillContent(fileName: string): Promise } // 解析 CSV -function parseCSV(text: string): BillRecord[] { +function parseCSV(text: string): UIBill[] { const lines = text.trim().split('\n'); if (lines.length < 2) return []; - const records: BillRecord[] = []; + const records: UIBill[] = []; // CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级 for (let i = 1; i < lines.length; i++) { @@ -191,13 +179,13 @@ function parseCSV(text: string): BillRecord[] { time: values[0] || '', category: values[1] || '', merchant: values[2] || '', - description: values[4] || '', // 跳过 values[3] (对方账号) - income_expense: values[5] || '', - amount: values[6] || '', - payment_method: values[7] || '', + description: values[4] || '', // 跳过 values[3] (对方账号) + incomeExpense: values[5] || '', + amount: Number(values[6] || 0), + paymentMethod: values[7] || '', status: values[8] || '', remark: values[11] || '', - needs_review: values[13] || '', // 复核等级在第14列 + reviewLevel: values[13] || '', // 复核等级在第14列 }); } } diff --git a/web/src/lib/components/analysis/BillDetailDrawer.svelte b/web/src/lib/components/analysis/BillDetailDrawer.svelte new file mode 100644 index 0000000..6a506d7 --- /dev/null +++ b/web/src/lib/components/analysis/BillDetailDrawer.svelte @@ -0,0 +1,277 @@ + + + + + + + + {isEditing ? '编辑账单' : title} + {@render titleExtra?.({ isEditing })} + + + {isEditing ? editDescription : viewDescription} + + + +
+ {#if record} + {#if isEditing} +
+
+ +
+ ¥ + +
+
+ +
+ + +
+ +
+ + {#if categories.length > 0} + + + {editForm.category || '选择分类'} + + + + {#each categories as category} + {category} + {/each} + + + + {:else} + + {/if} +
+ +
+ + +
+ +
+ + +
+
+ {:else} +
+
+
¥{record.amount.toFixed(2)}
+
支出金额
+
+ +
+
+ +
+
商家
+
{record.merchant}
+
+
+ +
+ +
+
分类
+
{record.category}
+
+
+ +
+ +
+
时间
+
{record.time}
+
+
+ + {#if record.description} +
+ +
+
描述
+
{record.description}
+
+
+ {/if} + + {#if record.paymentMethod} +
+ +
+
支付方式
+
{record.paymentMethod}
+
+
+ {/if} +
+
+ {/if} + {/if} +
+ + + {#if isEditing} + + + {:else} + + + {/if} + +
+
diff --git a/web/src/lib/components/analysis/BillRecordsTable.svelte b/web/src/lib/components/analysis/BillRecordsTable.svelte index 9ee707d..5976f9d 100644 --- a/web/src/lib/components/analysis/BillRecordsTable.svelte +++ b/web/src/lib/components/analysis/BillRecordsTable.svelte @@ -1,33 +1,21 @@ @@ -153,7 +60,7 @@

- ¥{record.amount} + ¥{record.amount.toFixed(2)}
{/each} @@ -161,157 +68,24 @@ - - - - - - - {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} + + {#snippet titleExtra({ isEditing })} + {#if selectedRank <= 3 && !isEditing} + + Top {selectedRank} + {/if} - - - {#if isEditing} - - - {:else} - - - {/if} - -
-
+ {/snippet} + diff --git a/web/src/lib/components/ui/chart/chart-container.svelte b/web/src/lib/components/ui/chart/chart-container.svelte index 36c0000..551ef20 100644 --- a/web/src/lib/components/ui/chart/chart-container.svelte +++ b/web/src/lib/components/ui/chart/chart-container.svelte @@ -17,7 +17,7 @@ config: ChartConfig; } = $props(); - const chartId = `chart-${id || uid.replace(/:/g, "")}`; + let chartId = $derived.by(() => `chart-${id || uid.replace(/:/g, "")}`); setChartContext({ get config() { diff --git a/web/src/lib/data/demo.ts b/web/src/lib/data/demo.ts index 149046d..23e3ab6 100644 --- a/web/src/lib/data/demo.ts +++ b/web/src/lib/data/demo.ts @@ -1,10 +1,23 @@ -import type { BillRecord } from '$lib/api'; +import type { UIBill } from '$lib/models/bill'; + +type DemoBillRow = { + time: string; + category: string; + merchant: string; + description: string; + income_expense: string; + amount: string; + payment_method: string; + status: string; + remark: string; + needs_review: string; +}; /** * 真实账单数据(来自支付宝和微信导出) * 数据已脱敏处理 */ -export const demoRecords: BillRecord[] = [ +const demoRows: DemoBillRow[] = [ // ========== 支付宝数据 ========== { time: "2026-01-07 12:01:02", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" }, { time: "2026-01-06 15:54:53", category: "餐饮美食", merchant: "友宝", description: "智能货柜消费", income_expense: "支出", amount: "7.19", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" }, @@ -167,3 +180,16 @@ export const demoRecords: BillRecord[] = [ { time: "2025-12-08 19:15:45", category: "餐饮美食", merchant: "瑞幸咖啡", description: "咖啡", income_expense: "支出", amount: "12.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" }, { time: "2025-12-07 18:42:19", category: "餐饮美食", merchant: "奶茶店", description: "饮品", income_expense: "支出", amount: "15.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" }, ].sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()); + +export const demoRecords: UIBill[] = demoRows.map((r) => ({ + time: r.time, + category: r.category, + merchant: r.merchant, + description: r.description || '', + incomeExpense: r.income_expense, + amount: Number(r.amount || 0), + paymentMethod: r.payment_method || '', + status: r.status || '', + remark: r.remark || '', + reviewLevel: r.needs_review || '', +})); diff --git a/web/src/lib/models/bill.ts b/web/src/lib/models/bill.ts new file mode 100644 index 0000000..877d8be --- /dev/null +++ b/web/src/lib/models/bill.ts @@ -0,0 +1,45 @@ +import type { CleanedBill, UpdateBillRequest } from '$lib/api'; + +export interface UIBill { + id?: string; + time: string; + category: string; + merchant: string; + description?: string; + incomeExpense: string; + amount: number; + paymentMethod?: string; + status?: string; + remark?: string; + reviewLevel?: string; +} + +export function cleanedBillToUIBill(bill: CleanedBill): UIBill { + return { + id: bill.id, + time: bill.time, + category: bill.category, + merchant: bill.merchant, + description: bill.description || '', + incomeExpense: bill.income_expense, + amount: bill.amount, + paymentMethod: bill.pay_method || '', + status: bill.status || '', + remark: bill.remark || '', + reviewLevel: bill.review_level || '', + }; +} + +export function uiBillToUpdateBillRequest(bill: UIBill): UpdateBillRequest { + return { + time: bill.time, + category: bill.category, + merchant: bill.merchant, + description: bill.description, + income_expense: bill.incomeExpense, + amount: bill.amount, + pay_method: bill.paymentMethod, + status: bill.status, + remark: bill.remark, + }; +} diff --git a/web/src/lib/services/analysis.ts b/web/src/lib/services/analysis.ts index 275c288..6c1256f 100644 --- a/web/src/lib/services/analysis.ts +++ b/web/src/lib/services/analysis.ts @@ -1,11 +1,11 @@ -import type { BillRecord } from '$lib/api'; +import type { UIBill } from '$lib/models/bill'; import type { CategoryStat, MonthlyStat, DailyExpenseData, TotalStats, PieChartDataItem } from '$lib/types/analysis'; import { pieColors } from '$lib/constants/chart'; /** * 计算分类统计 */ -export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] { +export function calculateCategoryStats(records: UIBill[]): CategoryStat[] { const stats = new Map(); for (const r of records) { @@ -14,8 +14,8 @@ export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] { } const s = stats.get(r.category)!; s.count++; - const amount = parseFloat(r.amount || '0'); - if (r.income_expense === '支出') { + const amount = r.amount || 0; + if (r.incomeExpense === '支出') { s.expense += amount; } else { s.income += amount; @@ -30,7 +30,7 @@ export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] { /** * 计算月度统计 */ -export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] { +export function calculateMonthlyStats(records: UIBill[]): MonthlyStat[] { const stats = new Map(); for (const r of records) { @@ -39,8 +39,8 @@ export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] { stats.set(month, { expense: 0, income: 0 }); } const s = stats.get(month)!; - const amount = parseFloat(r.amount || '0'); - if (r.income_expense === '支出') { + const amount = r.amount || 0; + if (r.incomeExpense === '支出') { s.expense += amount; } else { s.income += amount; @@ -55,13 +55,13 @@ export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] { /** * 计算每日支出数据(用于面积图) */ -export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseData[] { +export function calculateDailyExpenseData(records: UIBill[]): DailyExpenseData[] { const stats = new Map(); for (const r of records) { - if (r.income_expense !== '支出') continue; + if (r.incomeExpense !== '支出') continue; const date = r.time.substring(0, 10); // YYYY-MM-DD - const amount = parseFloat(r.amount || '0'); + const amount = r.amount || 0; stats.set(date, (stats.get(date) || 0) + amount); } @@ -73,14 +73,14 @@ export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseDa /** * 计算总计统计 */ -export function calculateTotalStats(records: BillRecord[]): TotalStats { +export function calculateTotalStats(records: UIBill[]): TotalStats { return { expense: records - .filter(r => r.income_expense === '支出') - .reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0), + .filter(r => r.incomeExpense === '支出') + .reduce((sum, r) => sum + (r.amount || 0), 0), income: records - .filter(r => r.income_expense === '收入') - .reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0), + .filter(r => r.incomeExpense === '收入') + .reduce((sum, r) => sum + (r.amount || 0), 0), count: records.length, }; } @@ -112,18 +112,18 @@ export function calculatePieChartData( /** * 获取 Top N 支出记录 */ -export function getTopExpenses(records: BillRecord[], n: number = 10): BillRecord[] { +export function getTopExpenses(records: UIBill[], n: number = 10): UIBill[] { return records - .filter(r => r.income_expense === '支出') - .sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount)) + .filter(r => r.incomeExpense === '支出') + .sort((a, b) => (b.amount || 0) - (a.amount || 0)) .slice(0, n); } /** * 统计支出/收入笔数 */ -export function countByType(records: BillRecord[], type: '支出' | '收入'): number { - return records.filter(r => r.income_expense === type).length; +export function countByType(records: UIBill[], type: '支出' | '收入'): number { + return records.filter(r => r.incomeExpense === type).length; } diff --git a/web/src/lib/types/analysis.ts b/web/src/lib/types/analysis.ts index 4961d52..a7b2bb7 100644 --- a/web/src/lib/types/analysis.ts +++ b/web/src/lib/types/analysis.ts @@ -1,4 +1,4 @@ -import type { BillRecord } from '$lib/api'; +import type { UIBill } from '$lib/models/bill'; /** 分类统计数据 */ export interface CategoryStat { @@ -47,7 +47,7 @@ export interface AnalysisState { fileName: string; isLoading: boolean; errorMessage: string; - records: BillRecord[]; + records: UIBill[]; isDemo: boolean; categoryChartMode: 'bar' | 'pie'; } diff --git a/web/src/routes/analysis/+page.svelte b/web/src/routes/analysis/+page.svelte index 1fb324f..a7a7b3a 100644 --- a/web/src/routes/analysis/+page.svelte +++ b/web/src/routes/analysis/+page.svelte @@ -1,6 +1,7 @@