Files
billai/web/src/routes/review/+page.svelte
clz aa4f1615ce fix: 统一各页面账单分类来源
bills 和 review 页面改从 $lib/data/categories 导入分类列表,
删除本地重复硬编码的旧版 13 项分类。
BillDetailDrawer 的 categories prop 类型改为 readonly string[]
以兼容 as const 导出的元组类型。
2026-03-03 20:50:45 +08:00

381 lines
14 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 { 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}
/>