Files
billai/web/src/routes/bills/+page.svelte
2026-01-26 13:44:22 +08:00

553 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { fetchBills, type CleanedBill } from '$lib/api';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import * as Select from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
import { formatLocalDate, formatDateTime } from '$lib/utils';
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';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import FileText from '@lucide/svelte/icons/file-text';
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';
// 状态
let isLoading = $state(false);
let errorMessage = $state('');
let records: CleanedBill[] = $state([]);
let activeTab = $state<'list' | 'manual'>('list'); // 'list' 或 'manual'
// 初始化标签页从URL查询参数
onMount(() => {
const tabParam = $page.url.searchParams.get('tab');
if (tabParam === 'manual') {
activeTab = 'manual';
}
});
// 分页
let currentPage = $state(1);
let pageSize = $state(20);
let totalRecords = $state(0);
let totalPages = $state(0);
// 聚合统计(所有筛选条件下的数据)
let totalExpense = $state(0);
let totalIncome = $state(0);
// 计算默认日期(当前月)
function getDefaultDates() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const startDate = formatLocalDate(new Date(year, month, 1));
const endDate = formatLocalDate(today);
return { startDate, endDate };
}
const defaultDates = getDefaultDates();
// 筛选
let filterCategory = $state('');
let filterIncomeExpense = $state(''); // 收支类型
let filterBillType = $state(''); // 账单来源
let startDate = $state(defaultDates.startDate);
let endDate = $state(defaultDates.endDate);
let searchText = $state('');
// Select 变化处理
function handleCategoryChange(value: string | undefined) {
filterCategory = value || '';
applyFilters();
}
function handleIncomeExpenseChange(value: string | undefined) {
filterIncomeExpense = value || '';
applyFilters();
}
function handleBillTypeChange(value: string | undefined) {
filterBillType = value || '';
applyFilters();
}
// 分类列表(硬编码常用分类)
const categories = [
'餐饮美食', '交通出行', '生活服务', '日用百货',
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
];
async function loadBills() {
isLoading = true;
errorMessage = '';
try {
const response = await fetchBills({
page: currentPage,
page_size: pageSize,
start_date: startDate || undefined,
end_date: endDate || undefined,
category: filterCategory || undefined,
type: filterBillType || undefined,
income_expense: filterIncomeExpense || undefined,
});
if (response.result && response.data) {
records = response.data.bills || [];
totalRecords = response.data.total;
totalPages = response.data.pages;
totalExpense = response.data.total_expense || 0;
totalIncome = response.data.total_income || 0;
} else {
errorMessage = response.message || '加载失败';
records = [];
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '加载失败';
records = [];
} finally {
isLoading = false;
}
}
// 切换页面
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadBills();
}
}
// 筛选变化时重置到第一页
function applyFilters() {
currentPage = 1;
loadBills();
}
// 清除筛选(恢复默认值)
function clearFilters() {
filterCategory = '';
filterIncomeExpense = '';
filterBillType = '';
startDate = defaultDates.startDate;
endDate = defaultDates.endDate;
searchText = '';
currentPage = 1;
loadBills();
}
// 本地搜索(在当前页数据中筛选)
let displayRecords = $derived(
searchText
? records.filter(r => {
const text = searchText.toLowerCase();
return r.merchant?.toLowerCase().includes(text) ||
r.description?.toLowerCase().includes(text);
})
: records
);
// 页面加载时获取数据
onMount(() => {
loadBills();
});
// 手动账单提交成功回调
function handleManualBillSuccess() {
// 切换回列表标签页
activeTab = 'list';
// 重置分页到第一页
currentPage = 1;
// 重新加载账单列表(保持当前日期筛选)
loadBills();
}
// 账单详情抽屉
let drawerOpen = $state(false);
let selectedBill = $state<UIBill | null>(null);
// 点击行打开详情
function handleRowClick(record: CleanedBill) {
selectedBill = cleanedBillToUIBill(record);
drawerOpen = true;
}
// 更新账单后刷新列表
function handleBillUpdate(updated: UIBill) {
// 在当前列表中更新对应的记录
const index = records.findIndex(r => r.id === updated.id);
if (index !== -1) {
records[index] = {
...records[index],
time: updated.time,
category: updated.category,
merchant: updated.merchant,
description: updated.description || '',
income_expense: updated.incomeExpense,
amount: updated.amount,
pay_method: updated.paymentMethod || '',
status: updated.status || '',
remark: updated.remark || '',
review_level: updated.reviewLevel || '',
};
}
}
// 删除账单后刷新列表
function handleBillDelete(deleted: UIBill) {
// 从列表中移除对应的记录
records = records.filter(r => r.id !== deleted.id);
totalRecords = Math.max(0, totalRecords - 1);
// 更新聚合统计
if (deleted.incomeExpense === '支出') {
totalExpense = Math.max(0, totalExpense - deleted.amount);
} else if (deleted.incomeExpense === '收入') {
totalIncome = Math.max(0, totalIncome - deleted.amount);
}
}
</script>
<svelte:head>
<title>账单列表 - BillAI</title>
</svelte:head>
<div class="space-y-6">
<!-- 页面标题和标签切换 -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">账单管理</h1>
<p class="text-muted-foreground">查看、筛选和手动添加账单记录</p>
</div>
<div class="flex items-center gap-2">
{#if activeTab === 'list'}
<Button variant="outline" onclick={loadBills} disabled={isLoading}>
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
刷新
</Button>
{/if}
</div>
</div>
<!-- 标签切换 -->
<div class="flex gap-1">
<Button
variant={activeTab === 'list' ? 'default' : 'ghost'}
onclick={() => activeTab = 'list'}
>
<List class="mr-2 h-4 w-4" />
账单列表
</Button>
<Button
variant={activeTab === 'manual' ? 'default' : 'ghost'}
onclick={() => activeTab = 'manual'}
>
<Plus class="mr-2 h-4 w-4" />
手动添加
</Button>
</div>
<Separator />
<!-- 账单列表视图 -->
{#if activeTab === 'list'}
<div class="space-y-6">
<!-- 错误提示 -->
{#if errorMessage}
<div class="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{errorMessage}
</div>
{/if}
<!-- 统计概览 -->
<div class="grid gap-4 md:grid-cols-3">
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总交易笔数</Card.Title>
<Receipt class="h-4 w-4 text-muted-foreground" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{totalRecords}</div>
<p class="text-xs text-muted-foreground">筛选条件下的账单总数</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-red-200 dark:border-red-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-red-300 dark:hover:border-red-800 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总支出</Card.Title>
<TrendingDown class="h-4 w-4 text-red-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-red-600 dark:text-red-400">
¥{totalExpense.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">筛选条件下的支出汇总</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-green-200 dark:border-green-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-green-300 dark:hover:border-green-800 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总收入</Card.Title>
<TrendingUp class="h-4 w-4 text-green-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-green-600 dark:text-green-400">
¥{totalIncome.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">筛选条件下的收入汇总</p>
</Card.Content>
</Card.Root>
</div>
<!-- 筛选和表格 -->
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<Card.Title class="flex items-center gap-2">
<Filter class="h-5 w-5" />
筛选条件
</Card.Title>
{#if filterCategory || filterIncomeExpense || filterBillType || startDate || endDate}
<Button variant="ghost" size="sm" onclick={clearFilters}>
清除筛选
</Button>
{/if}
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div class="space-y-1.5 col-span-2 sm:col-span-2">
<Label class="text-xs">日期范围</Label>
<DateRangePicker
bind:startDate
bind:endDate
onchange={(start, end) => {
applyFilters();
}}
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">分类</Label>
<Select.Root type="single" value={filterCategory || undefined} onValueChange={handleCategoryChange}>
<Select.Trigger class="h-9 w-full">
<span class="text-sm">{filterCategory || '全部'}</span>
</Select.Trigger>
<Select.Portal>
<Select.Content>
<Select.Item value="">全部</Select.Item>
{#each categories as cat}
<Select.Item value={cat}>{cat}</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">收/支</Label>
<Select.Root type="single" value={filterIncomeExpense || undefined} onValueChange={handleIncomeExpenseChange}>
<Select.Trigger class="h-9 w-full">
<span class="text-sm">{filterIncomeExpense || '全部'}</span>
</Select.Trigger>
<Select.Portal>
<Select.Content>
<Select.Item value="">全部</Select.Item>
<Select.Item value="支出">支出</Select.Item>
<Select.Item value="收入">收入</Select.Item>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">来源</Label>
<Select.Root type="single" value={filterBillType || undefined} onValueChange={handleBillTypeChange}>
<Select.Trigger class="h-9 w-full">
<span class="text-sm">{filterBillType === 'alipay' ? '支付宝' : filterBillType === 'wechat' ? '微信' : filterBillType === 'jd' ? '京东' : filterBillType === 'manual' ? '手动' : '全部'}</span>
</Select.Trigger>
<Select.Portal>
<Select.Content>
<Select.Item value="">全部</Select.Item>
<Select.Item value="alipay">支付宝</Select.Item>
<Select.Item value="wechat">微信</Select.Item>
<Select.Item value="jd">京东</Select.Item>
<Select.Item value="manual">手动</Select.Item>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<div class="space-y-1.5 col-span-2 sm:col-span-1">
<Label class="text-xs">搜索</Label>
<div class="relative">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="商家/商品..."
class="pl-8"
bind:value={searchText}
/>
</div>
</div>
</div>
</div>
</Card.Header>
<Card.Content>
{#if isLoading}
<div class="flex flex-col items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground mb-4" />
<p class="text-muted-foreground">加载中...</p>
</div>
{:else if displayRecords.length > 0}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px] lg:w-[160px]">时间</Table.Head>
<Table.Head class="hidden xl:table-cell">来源</Table.Head>
<Table.Head>分类</Table.Head>
<Table.Head class="hidden sm:table-cell">交易对方</Table.Head>
<Table.Head class="hidden lg:table-cell">商品说明</Table.Head>
<Table.Head class="hidden min-[480px]:table-cell">收/支</Table.Head>
<Table.Head class="text-right">金额</Table.Head>
<Table.Head class="hidden xl:table-cell">支付方式</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each displayRecords as record}
<Table.Row
class="cursor-pointer hover:bg-muted/50 transition-colors"
onclick={() => handleRowClick(record)}
>
<Table.Cell class="text-muted-foreground text-sm">
{formatDateTime(record.time)}
</Table.Cell>
<Table.Cell class="hidden xl:table-cell">
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : (record.bill_type === 'jd' ? 'destructive' : 'secondary'))}>
{record.bill_type === 'manual' ? '手动输入' : (record.bill_type === 'alipay' ? '支付宝' : (record.bill_type === 'jd' ? '京东' : '微信'))}
</Badge>
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{record.category}</Badge>
</Table.Cell>
<Table.Cell class="hidden sm:table-cell max-w-[100px] md:max-w-[150px] truncate" title={record.merchant}>
{record.merchant}
</Table.Cell>
<Table.Cell class="hidden lg:table-cell max-w-[150px] truncate text-muted-foreground" title={record.description}>
{record.description || '-'}
</Table.Cell>
<Table.Cell class="hidden min-[480px]:table-cell">
<span class={record.income_expense === '支出' ? 'text-red-500' : 'text-green-500'}>
{record.income_expense}
</span>
</Table.Cell>
<Table.Cell class="text-right font-mono font-medium">
¥{record.amount.toFixed(2)}
</Table.Cell>
<Table.Cell class="hidden xl:table-cell text-muted-foreground text-sm">
{record.pay_method || '-'}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<!-- 分页控件 -->
<div class="flex items-center justify-between mt-4">
<p class="text-sm text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalRecords)} 条,共 {totalRecords}
{#if searchText}
<span class="text-muted-foreground/70">(当前页筛选后:{displayRecords.length} 条)</span>
{/if}
</p>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-4 w-4" />
上一页
</Button>
<div class="flex items-center gap-1">
{#each (() => {
// 计算显示的页码范围最多显示5页
const maxPages = 5;
let start = Math.max(1, currentPage - Math.floor(maxPages / 2));
let end = Math.min(totalPages, start + maxPages - 1);
// 如果右侧空间不足,向左调整
if (end - start < maxPages - 1) {
start = Math.max(1, end - maxPages + 1);
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
})() as page}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
class="w-9"
onclick={() => goToPage(page)}
>
{page}
</Button>
{/each}
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onclick={() => goToPage(currentPage + 1)}
>
下一页
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-12 text-center">
<FileText class="h-12 w-12 text-muted-foreground mb-4" />
<p class="text-muted-foreground">没有找到账单记录</p>
<p class="text-sm text-muted-foreground mt-1">请先上传账单或调整筛选条件</p>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
{/if}
<!-- 手动添加视图 -->
{#if activeTab === 'manual'}
<ManualBillInput onSuccess={handleManualBillSuccess} />
{/if}
</div>
<!-- 账单详情抽屉 -->
<BillDetailDrawer
bind:open={drawerOpen}
bind:record={selectedBill}
categories={categories}
title="账单详情"
viewDescription="查看这笔账单的完整信息"
editDescription="修改这笔账单的信息"
allowDelete={true}
onUpdate={handleBillUpdate}
onDelete={handleBillDelete}
/>