feat: 新增账单导出 Excel 功能

- 后端新增 /api/bills/export 接口,支持当前筛选条件导出全部记录
- 使用 excelize 库生成 xlsx 格式文件
- 前端账单管理页面添加导出按钮
- 更新 Go 版本到 1.24 以支持 excelize 依赖
This commit is contained in:
clz
2026-03-23 19:16:54 +08:00
parent d813fe4307
commit 02de11caac
9 changed files with 312 additions and 173 deletions

View File

@@ -316,6 +316,46 @@ export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsRe
return response.json();
}
// 导出账单为 Excel
export async function exportBills(params: FetchBillsParams = {}): Promise<void> {
const searchParams = new URLSearchParams();
if (params.start_date) searchParams.set('start_date', params.start_date);
if (params.end_date) searchParams.set('end_date', params.end_date);
if (params.category) searchParams.set('category', params.category);
if (params.type) searchParams.set('type', params.type);
if (params.income_expense) searchParams.set('income_expense', params.income_expense);
const queryString = searchParams.toString();
const url = `${API_BASE}/api/bills/export${queryString ? '?' + queryString : ''}`;
const response = await apiFetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `bills_${new Date().toISOString().slice(0, 10)}.xlsx`;
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
filename = match[1].replace(/['"]/g, '');
}
}
const objectUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(objectUrl);
}
// 手动输入账单数据
export interface ManualBillInput {
time: string;

View File

@@ -15,8 +15,9 @@
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
import { categories } from '$lib/data/categories';
import { formatLocalDate, formatDateTime } from '$lib/utils';
import Loader2 from '@lucide/svelte/icons/loader-2';
import { formatLocalDate, formatDateTime } from '$lib/utils';
import { exportBills } from '$lib/api';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import Search from '@lucide/svelte/icons/search';
import Receipt from '@lucide/svelte/icons/receipt';
@@ -26,9 +27,10 @@
import Filter from '@lucide/svelte/icons/filter';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Plus from '@lucide/svelte/icons/plus';
import List from '@lucide/svelte/icons/list';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Plus from '@lucide/svelte/icons/plus';
import List from '@lucide/svelte/icons/list';
import Download from '@lucide/svelte/icons/download';
// 状态
let isLoading = $state(false);
@@ -221,6 +223,29 @@
totalIncome = Math.max(0, totalIncome - deleted.amount);
}
}
// 导出 Excel
let isExporting = $state(false);
let exportError = $state('');
async function handleExport() {
isExporting = true;
exportError = '';
try {
await exportBills({
start_date: startDate || undefined,
end_date: endDate || undefined,
category: filterCategory || undefined,
type: filterBillType || undefined,
income_expense: filterIncomeExpense || undefined,
});
} catch (err) {
exportError = err instanceof Error ? err.message : '导出失败';
} finally {
isExporting = false;
}
}
</script>
<svelte:head>
@@ -240,6 +265,10 @@
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
刷新
</Button>
<Button variant="outline" onclick={handleExport} disabled={isExporting || totalRecords === 0}>
<Download class="mr-2 h-4 w-4 {isExporting ? 'animate-spin' : ''}" />
导出 Excel
</Button>
{/if}
</div>
</div>