bills 和 review 页面改从 $lib/data/categories 导入分类列表, 删除本地重复硬编码的旧版 13 项分类。 BillDetailDrawer 的 categories prop 类型改为 readonly string[] 以兼容 as const 导出的元组类型。
381 lines
14 KiB
Svelte
381 lines
14 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { fetchReviewStats, fetchBillsByReviewLevel, type ReviewData, 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 * as Table from '$lib/components/ui/table';
|
||
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
|
||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||
import { updateBill } from '$lib/api';
|
||
import { categories } from '$lib/data/categories';
|
||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||
import Clock from '@lucide/svelte/icons/clock';
|
||
import PartyPopper from '@lucide/svelte/icons/party-popper';
|
||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||
import Check from '@lucide/svelte/icons/check';
|
||
|
||
let isLoading = $state(true);
|
||
let errorMessage = $state('');
|
||
let reviewStats = $state<ReviewData | null>(null);
|
||
let allBills = $state<CleanedBill[]>([]);
|
||
let filterLevel = $state<'all' | 'HIGH' | 'LOW'>('all');
|
||
|
||
// 快捷确认按钮的加载状态 (记录ID -> 是否在加载)
|
||
let confirmingBills = $state<Map<string, boolean>>(new Map());
|
||
|
||
onMount(() => {
|
||
loadReviewData();
|
||
});
|
||
|
||
async function loadReviewData() {
|
||
isLoading = true;
|
||
errorMessage = '';
|
||
|
||
try {
|
||
// 并行加载统计和账单数据
|
||
const [statsResponse, billsResponse] = await Promise.all([
|
||
fetchReviewStats(),
|
||
fetchBillsByReviewLevel()
|
||
]);
|
||
|
||
if (statsResponse.result && statsResponse.data) {
|
||
reviewStats = statsResponse.data;
|
||
} else {
|
||
errorMessage = statsResponse.message || '获取统计数据失败';
|
||
}
|
||
|
||
if (billsResponse.result) {
|
||
// bills 可能为 null(没有待复核数据),这是正常情况
|
||
const bills = billsResponse.data?.bills || [];
|
||
// 过滤出有复核等级的账单(HIGH或LOW)
|
||
allBills = bills.filter(bill =>
|
||
bill.review_level === 'HIGH' || bill.review_level === 'LOW'
|
||
);
|
||
} else {
|
||
errorMessage = billsResponse.message || '获取账单数据失败';
|
||
}
|
||
} catch (err) {
|
||
errorMessage = err instanceof Error ? err.message : '网络错误';
|
||
console.error('Failed to load review data:', err);
|
||
} finally {
|
||
isLoading = false;
|
||
}
|
||
}
|
||
|
||
// 过滤后的记录
|
||
let filteredRecords = $derived(
|
||
allBills.filter(bill =>
|
||
filterLevel === 'all' || bill.review_level === filterLevel
|
||
) || []
|
||
);
|
||
|
||
// 统计数据
|
||
let totalCount = $derived(reviewStats?.total || 0);
|
||
let highCount = $derived(reviewStats?.high || 0);
|
||
let lowCount = $derived(reviewStats?.low || 0);
|
||
|
||
// 账单详情抽屉
|
||
let drawerOpen = $state(false);
|
||
let selectedBill = $state<UIBill | null>(null);
|
||
|
||
|
||
|
||
// 点击行打开详情
|
||
function handleRowClick(record: CleanedBill) {
|
||
selectedBill = cleanedBillToUIBill(record);
|
||
drawerOpen = true;
|
||
}
|
||
|
||
// 快捷确认(仅清除 review_level,不修改其他字段)
|
||
async function quickConfirm(record: CleanedBill, event: Event) {
|
||
// 阻止事件冒泡,避免触发行点击
|
||
event.stopPropagation();
|
||
|
||
if (confirmingBills.get(record.id)) return;
|
||
|
||
// 设置加载状态
|
||
confirmingBills.set(record.id, true);
|
||
confirmingBills = new Map(confirmingBills);
|
||
|
||
try {
|
||
const resp = await updateBill(record.id, { review_level: '' });
|
||
|
||
if (resp.result) {
|
||
// 从列表中移除该记录
|
||
const index = allBills.findIndex(r => r.id === record.id);
|
||
if (index !== -1) {
|
||
allBills.splice(index, 1);
|
||
allBills = [...allBills];
|
||
}
|
||
|
||
// 更新统计数据
|
||
if (reviewStats) {
|
||
reviewStats = {
|
||
...reviewStats,
|
||
total: Math.max(0, reviewStats.total - 1),
|
||
high: record.review_level === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
|
||
low: record.review_level === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
|
||
};
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('快捷确认失败:', err);
|
||
// 这里可以添加错误提示
|
||
} finally {
|
||
// 清除加载状态
|
||
confirmingBills.delete(record.id);
|
||
confirmingBills = new Map(confirmingBills);
|
||
}
|
||
}
|
||
|
||
// 复核完成后从列表中移除该记录
|
||
function handleBillUpdate(updated: UIBill, original: UIBill) {
|
||
// 更新后 review_level 已被清除,从列表中移除
|
||
const index = allBills.findIndex(r => r.id === updated.id);
|
||
if (index !== -1) {
|
||
allBills.splice(index, 1);
|
||
// 触发响应式更新
|
||
allBills = [...allBills];
|
||
}
|
||
// 更新统计数据(根据原始的 review_level 减少计数)
|
||
if (reviewStats) {
|
||
reviewStats = {
|
||
...reviewStats,
|
||
total: Math.max(0, reviewStats.total - 1),
|
||
high: original.reviewLevel === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
|
||
low: original.reviewLevel === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
|
||
};
|
||
}
|
||
// 关闭抽屉
|
||
drawerOpen = false;
|
||
}
|
||
|
||
// 删除账单后从列表中移除该记录
|
||
function handleBillDelete(deleted: UIBill) {
|
||
// 从列表中移除对应的记录
|
||
const index = allBills.findIndex(r => r.id === deleted.id);
|
||
if (index !== -1) {
|
||
allBills.splice(index, 1);
|
||
allBills = [...allBills];
|
||
}
|
||
// 更新统计数据
|
||
if (reviewStats) {
|
||
reviewStats = {
|
||
...reviewStats,
|
||
total: Math.max(0, reviewStats.total - 1),
|
||
high: deleted.reviewLevel === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
|
||
low: deleted.reviewLevel === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
|
||
};
|
||
}
|
||
}
|
||
</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>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onclick={loadReviewData}
|
||
disabled={isLoading}
|
||
>
|
||
{#if isLoading}
|
||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||
{:else}
|
||
<RefreshCw class="mr-2 h-4 w-4" />
|
||
{/if}
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- 加载中状态 -->
|
||
{#if isLoading}
|
||
<Card.Root>
|
||
<Card.Content 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>
|
||
</Card.Content>
|
||
</Card.Root>
|
||
{/if}
|
||
|
||
<!-- 错误提示 -->
|
||
{#if errorMessage && !isLoading}
|
||
<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}
|
||
|
||
{#if reviewStats && !isLoading}
|
||
<!-- 统计卡片 -->
|
||
<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>
|
||
<Clock class="h-4 w-4 text-muted-foreground" />
|
||
</Card.Header>
|
||
<Card.Content>
|
||
<div class="text-2xl font-bold">{totalCount}</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>
|
||
<AlertTriangle class="h-4 w-4 text-red-500" />
|
||
</Card.Header>
|
||
<Card.Content>
|
||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">{highCount}</div>
|
||
<p class="text-xs text-muted-foreground">无法确定分类</p>
|
||
</Card.Content>
|
||
</Card.Root>
|
||
|
||
<Card.Root class="border-yellow-200 dark:border-yellow-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-yellow-300 dark:hover:border-yellow-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>
|
||
<AlertCircle class="h-4 w-4 text-yellow-500" />
|
||
</Card.Header>
|
||
<Card.Content>
|
||
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">{lowCount}</div>
|
||
<p class="text-xs text-muted-foreground">分类可能有变更</p>
|
||
</Card.Content>
|
||
</Card.Root>
|
||
</div>
|
||
|
||
<!-- 过滤器和表格 -->
|
||
<Card.Root>
|
||
<Card.Header>
|
||
<div class="flex items-center justify-between">
|
||
<Card.Title>记录列表</Card.Title>
|
||
<div class="flex gap-2">
|
||
<Button
|
||
variant={filterLevel === 'all' ? 'default' : 'outline'}
|
||
size="sm"
|
||
onclick={() => filterLevel = 'all'}
|
||
>
|
||
全部 ({totalCount})
|
||
</Button>
|
||
<Button
|
||
variant={filterLevel === 'HIGH' ? 'destructive' : 'outline'}
|
||
size="sm"
|
||
onclick={() => filterLevel = 'HIGH'}
|
||
>
|
||
高优先级 ({highCount})
|
||
</Button>
|
||
<Button
|
||
variant={filterLevel === 'LOW' ? 'secondary' : 'outline'}
|
||
size="sm"
|
||
onclick={() => filterLevel = 'LOW'}
|
||
>
|
||
低优先级 ({lowCount})
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card.Header>
|
||
<Card.Content>
|
||
{#if filteredRecords.length > 0}
|
||
<div class="rounded-md border">
|
||
<Table.Root>
|
||
<Table.Header>
|
||
<Table.Row>
|
||
<Table.Head class="w-[160px]">交易时间</Table.Head>
|
||
<Table.Head>分类</Table.Head>
|
||
<Table.Head>交易对方</Table.Head>
|
||
<Table.Head>商品说明</Table.Head>
|
||
<Table.Head>收/支</Table.Head>
|
||
<Table.Head class="text-right">金额</Table.Head>
|
||
<Table.Head class="w-[80px]">优先级</Table.Head>
|
||
<Table.Head class="w-[100px] text-center">操作</Table.Head>
|
||
</Table.Row>
|
||
</Table.Header>
|
||
<Table.Body>
|
||
{#each filteredRecords as record}
|
||
<Table.Row
|
||
class="cursor-pointer hover:bg-muted/50 transition-colors"
|
||
onclick={() => handleRowClick(record)}
|
||
>
|
||
<Table.Cell class="text-muted-foreground text-sm">
|
||
{record.time ? new Date(record.time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
<Badge variant="secondary">{record.category}</Badge>
|
||
</Table.Cell>
|
||
<Table.Cell class="max-w-[200px] truncate" title={record.merchant}>
|
||
{record.merchant || '-'}
|
||
</Table.Cell>
|
||
<Table.Cell class="max-w-[200px] truncate text-muted-foreground" title={record.description}>
|
||
{record.description || '-'}
|
||
</Table.Cell>
|
||
<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>
|
||
<Badge variant={record.review_level === 'HIGH' ? 'destructive' : 'outline'}>
|
||
{record.review_level}
|
||
</Badge>
|
||
</Table.Cell>
|
||
<Table.Cell class="text-center">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
class="h-7 px-2 text-xs"
|
||
onclick={(e) => quickConfirm(record, e)}
|
||
disabled={confirmingBills.get(record.id) || false}
|
||
title="确认分类正确"
|
||
>
|
||
{#if confirmingBills.get(record.id)}
|
||
<Loader2 class="h-3 w-3 animate-spin" />
|
||
{:else}
|
||
<Check class="h-3 w-3 mr-1" />
|
||
确认
|
||
{/if}
|
||
</Button>
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
{/each}
|
||
</Table.Body>
|
||
</Table.Root>
|
||
</div>
|
||
{:else}
|
||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||
<PartyPopper class="h-12 w-12 text-muted-foreground mb-4" />
|
||
<p class="text-muted-foreground">没有需要复核的记录</p>
|
||
<p class="text-sm text-muted-foreground">所有账单已正确分类</p>
|
||
</div>
|
||
{/if}
|
||
</Card.Content>
|
||
</Card.Root>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- 账单详情抽屉(复核模式) -->
|
||
<BillDetailDrawer
|
||
bind:open={drawerOpen}
|
||
bind:record={selectedBill}
|
||
categories={categories}
|
||
title="复核账单"
|
||
viewDescription="确认或修改这笔账单的分类"
|
||
editDescription="修改这笔账单的分类信息"
|
||
clearReviewLevel={true}
|
||
allowDelete={true}
|
||
onUpdate={handleBillUpdate}
|
||
onDelete={handleBillDelete}
|
||
/>
|