Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
- 将删除接口从 DELETE /api/bills/:id 改为 POST /api/bills/:id/delete 以兼容 SvelteKit 代理 - 分析页面组件 (TopExpenses/BillRecordsTable/DailyTrendChart) 支持删除并同步更新统计数据 - Review 接口改为直接查询 MongoDB 而非读取文件 - 软删除时记录 updated_at 时间戳 - 添加 .dockerignore 文件优化构建 - 完善 AGENTS.md 文档
306 lines
9.5 KiB
Svelte
306 lines
9.5 KiB
Svelte
<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}
|
||
/>
|