553 lines
20 KiB
Svelte
553 lines
20 KiB
Svelte
<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}
|
||
/>
|