Files
billai/web/src/lib/components/analysis/BillRecordsTable.svelte
clz 642ea2d3ef
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
fix: 修复账单删除功能并支持分析页面删除操作
- 将删除接口从 DELETE /api/bills/:id 改为 POST /api/bills/:id/delete 以兼容 SvelteKit 代理
- 分析页面组件 (TopExpenses/BillRecordsTable/DailyTrendChart) 支持删除并同步更新统计数据
- Review 接口改为直接查询 MongoDB 而非读取文件
- 软删除时记录 updated_at 时间戳
- 添加 .dockerignore 文件优化构建
- 完善 AGENTS.md 文档
2026-02-16 22:28:49 +08:00

306 lines
9.5 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 * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { type UIBill } from '$lib/models/bill';
import BillDetailDrawer from './BillDetailDrawer.svelte';
interface Props {
records: UIBill[];
showCategory?: boolean;
showDescription?: boolean;
pageSize?: number;
categories?: string[];
onUpdate?: (updated: UIBill, original: UIBill) => void;
onDelete?: (deleted: UIBill) => void;
}
let {
records = $bindable(),
showCategory = false,
showDescription = true,
pageSize = 10,
categories = [],
onUpdate,
onDelete
}: Props = $props();
// 排序状态
type SortField = 'time' | 'category' | 'merchant' | 'description' | 'amount';
type SortOrder = 'asc' | 'desc';
let sortField = $state<SortField>('time');
let sortOrder = $state<SortOrder>('desc');
// 分页状态
let currentPage = $state(1);
// 详情弹窗状态
let detailDialogOpen = $state(false);
let selectedRecord = $state<UIBill | null>(null);
// 排序后的记录
let sortedRecords = $derived.by(() => {
return records.toSorted((a, b) => {
let cmp = 0;
switch (sortField) {
case 'time':
cmp = new Date(a.time).getTime() - new Date(b.time).getTime();
break;
case 'category':
cmp = a.category.localeCompare(b.category);
break;
case 'merchant':
cmp = a.merchant.localeCompare(b.merchant);
break;
case 'description':
cmp = (a.description || '').localeCompare(b.description || '');
break;
case 'amount':
cmp = (a.amount || 0) - (b.amount || 0);
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
});
// 分页计算
let totalPages = $derived(Math.ceil(sortedRecords.length / pageSize));
let paginatedRecords = $derived(
sortedRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
function toggleSort(field: SortField) {
if (sortField === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortOrder = field === 'amount' ? 'desc' : 'asc';
}
currentPage = 1;
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
}
}
// 打开详情弹窗
function openDetail(record: UIBill) {
selectedRecord = record;
detailDialogOpen = true;
}
function handleRecordUpdated(updated: UIBill, original: UIBill) {
// 更新本地数据fallback按引用/关键字段查找)
const idx = records.findIndex(r => r === original);
const finalIdx = idx !== -1
? idx
: records.findIndex(r =>
r.time === original.time &&
r.merchant === original.merchant &&
r.amount === original.amount
);
if (finalIdx !== -1) {
records[finalIdx] = updated;
records = [...records];
}
onUpdate?.(updated, original);
}
function handleRecordDeleted(deleted: UIBill) {
const idx = records.findIndex(r => r === deleted);
const finalIdx = idx !== -1
? idx
: records.findIndex(r =>
r.time === deleted.time &&
r.merchant === deleted.merchant &&
r.amount === deleted.amount
);
if (finalIdx !== -1) {
records.splice(finalIdx, 1);
records = [...records];
}
onDelete?.(deleted);
}
// 重置分页(当记录变化时)
$effect(() => {
records;
currentPage = 1;
sortField = 'time';
sortOrder = 'desc';
});
</script>
{#if records.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[140px]">
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('time')}
>
时间
{#if sortField === 'time'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{#if showCategory}
<Table.Head class="w-[100px]">
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('category')}
>
分类
{#if sortField === 'category'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{/if}
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('merchant')}
>
商家
{#if sortField === 'merchant'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{#if showDescription}
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('description')}
>
描述
{#if sortField === 'description'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{/if}
<Table.Head class="text-right w-[100px]">
<button
class="flex items-center gap-1 ml-auto hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('amount')}
>
金额
{#if sortField === 'amount'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRecords as record, i}
<Table.Row
class="hover:bg-muted/50 transition-colors cursor-pointer"
onclick={() => openDetail(record)}
>
<Table.Cell class="text-muted-foreground text-xs">
{record.time.substring(0, 16)}
</Table.Cell>
{#if showCategory}
<Table.Cell class="text-sm">{record.category}</Table.Cell>
{/if}
<Table.Cell class="font-medium">{record.merchant}</Table.Cell>
{#if showDescription}
<Table.Cell class="text-muted-foreground truncate max-w-[200px]">
{record.description || '-'}
</Table.Cell>
{/if}
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
¥{record.amount.toFixed(2)}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<!-- 分页控件 -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-4 pt-4 border-t">
<div class="text-sm text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRecords.length)} 条,共 {sortedRecords.length}
</div>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-4 w-4" />
</Button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => goToPage(page)}
>
{page}
</Button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-muted-foreground">...</span>
{/if}
{/each}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === totalPages}
onclick={() => goToPage(currentPage + 1)}
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
{/if}
{:else}
<div class="text-center py-8 text-muted-foreground">
暂无记录
</div>
{/if}
<BillDetailDrawer
bind:open={detailDialogOpen}
bind:record={selectedRecord}
{categories}
title="账单详情"
viewDescription="查看这笔支出的详细信息"
editDescription="修改这笔支出的信息"
onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
allowDelete={true}
/>