From 65ea2fa4778b0590c46b52131a4cc716a8d4e72b Mon Sep 17 00:00:00 2001
From: clz
Date: Sun, 18 Jan 2026 21:14:54 +0800
Subject: [PATCH] refactor(web): unify bills as UIBill, remove BillRecord
---
web/src/lib/api.ts | 30 +-
.../analysis/BillDetailDrawer.svelte | 277 ++++++++++++++++
.../analysis/BillRecordsTable.svelte | 297 ++----------------
.../analysis/CategoryRanking.svelte | 6 +-
.../analysis/DailyTrendChart.svelte | 49 +--
.../components/analysis/OverviewCards.svelte | 4 +-
.../components/analysis/TopExpenses.svelte | 290 ++---------------
.../ui/chart/chart-container.svelte | 2 +-
web/src/lib/data/demo.ts | 30 +-
web/src/lib/models/bill.ts | 45 +++
web/src/lib/services/analysis.ts | 40 +--
web/src/lib/types/analysis.ts | 4 +-
web/src/routes/analysis/+page.svelte | 23 +-
13 files changed, 484 insertions(+), 613 deletions(-)
create mode 100644 web/src/lib/components/analysis/BillDetailDrawer.svelte
create mode 100644 web/src/lib/models/bill.ts
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}
+
+ {:else}
+
+
+
¥{record.amount.toFixed(2)}
+
支出金额
+
+
+
+
+
+
+
商家
+
{record.merchant}
+
+
+
+
+
+
+
分类
+
{record.category}
+
+
+
+
+
+ {#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 @@