Files
billai/web/src/routes/+page.svelte
2026-01-26 13:44:22 +08:00

502 lines
17 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { uploadBill, fetchMonthlyStats, fetchReviewStats, fetchBills, type UploadResponse, type BillType } 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 Upload from '@lucide/svelte/icons/upload';
import FileText from '@lucide/svelte/icons/file-text';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import X from '@lucide/svelte/icons/x';
import Download from '@lucide/svelte/icons/download';
import ClipboardCheck from '@lucide/svelte/icons/clipboard-check';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import Plus from '@lucide/svelte/icons/plus';
import { goto } from '$app/navigation';
let isDragOver = $state(false);
let selectedFile: File | null = $state(null);
let selectedType: BillType = $state('alipay');
let isUploading = $state(false);
let uploadResult: UploadResponse | null = $state(null);
let errorMessage = $state('');
let zipPassword = $state('');
let isZipFile = $state(false);
type StatTrend = 'up' | 'down';
interface StatCard {
title: string;
value: string;
change: string;
trend: StatTrend;
description: string;
}
// 实时统计数据
let stats = $state<StatCard[]>([
{
title: '本月支出',
value: '¥0.00',
change: '+0%',
trend: 'up',
description: '加载中...'
},
{
title: '本月收入',
value: '¥0.00',
change: '+0%',
trend: 'up',
description: '加载中...'
},
{
title: '待复核',
value: '0',
change: '+0%',
trend: 'up',
description: '需要人工确认'
},
{
title: '已处理账单',
value: '0',
change: '+0%',
trend: 'up',
description: '累计处理记录'
},
]);
// 加载真实统计数据
async function loadStats() {
try {
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
// 计算上月
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const previousMonth = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}`;
// 获取月度统计数据
const monthlyResponse = await fetchMonthlyStats();
const monthlyStats = monthlyResponse.data || [];
// 获取待复核统计
const reviewResponse = await fetchReviewStats();
const reviewTotal = reviewResponse.data?.total || 0;
// 获取已处理账单数量
const billsResponse = await fetchBills({ page_size: 1 });
const billTotal = billsResponse.data?.total || 0;
// 提取当月和上月的数据
const currentData = monthlyStats.find(m => m.month === currentMonth);
const previousData = monthlyStats.find(m => m.month === previousMonth);
// 计算支出变化百分比
const currentExpense = currentData?.expense || 0;
const previousExpense = previousData?.expense || 0;
const expenseChange = previousExpense > 0
? (currentExpense - previousExpense) / previousExpense * 100
: 0;
const expenseTrend: StatTrend = expenseChange >= 0 ? 'up' : 'down';
// 计算收入变化百分比
const currentIncome = currentData?.income || 0;
const previousIncome = previousData?.income || 0;
const incomeChange = previousIncome > 0
? (currentIncome - previousIncome) / previousIncome * 100
: 0;
const incomeTrend: StatTrend = incomeChange >= 0 ? 'up' : 'down';
// 格式化金额
const formatAmount = (amount: number) => {
return ${amount.toFixed(2)}`;
};
const formatChange = (change: number) => {
const sign = change >= 0 ? '+' : '';
return `${sign}${change.toFixed(1)}%`;
};
const newStats: StatCard[] = [
{
title: '本月支出',
value: formatAmount(currentExpense),
change: formatChange(expenseChange),
trend: expenseTrend,
description: '较上月' + (expenseTrend === 'up' ? '增加' : '减少')
},
{
title: '本月收入',
value: formatAmount(currentIncome),
change: formatChange(incomeChange),
trend: incomeTrend,
description: '较上月' + (incomeTrend === 'up' ? '增加' : '减少')
},
{
title: '待复核',
value: reviewTotal.toString(),
change: '+0%',
trend: 'up',
description: '需要人工确认'
},
{
title: '已处理账单',
value: billTotal.toString(),
change: '+0%',
trend: 'up',
description: '累计处理记录'
},
];
stats = newStats;
} catch (err) {
console.error('Failed to load stats:', err);
// 保持默认状态
}
}
onMount(() => {
loadStats();
});
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragOver = true;
}
function handleDragLeave() {
isDragOver = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragOver = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
selectFile(files[0]);
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
selectFile(input.files[0]);
}
}
function selectFile(file: File) {
const fileName = file.name.toLowerCase();
const isZip = fileName.endsWith('.zip');
const isCsv = fileName.endsWith('.csv');
const isXlsx = fileName.endsWith('.xlsx');
if (!isCsv && !isZip && !isXlsx) {
errorMessage = '请选择 CSV、XLSX 或 ZIP 格式的账单文件';
return;
}
selectedFile = file;
isZipFile = isZip;
errorMessage = '';
uploadResult = null;
// 如果不是 ZIP 文件,清空密码
if (!isZip) {
zipPassword = '';
}
// 根据文件名自动识别账单类型
if (fileName.includes('支付宝') || fileName.includes('alipay')) {
selectedType = 'alipay';
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
selectedType = 'wechat';
} else if (fileName.includes('京东') || fileName.includes('jd')) {
selectedType = 'jd';
}
}
function clearFile() {
selectedFile = null;
uploadResult = null;
errorMessage = '';
zipPassword = '';
isZipFile = false;
}
async function handleUpload() {
if (!selectedFile) return;
isUploading = true;
errorMessage = '';
try {
const options: { year?: number; month?: number; password?: string } = {};
if (isZipFile && zipPassword) {
options.password = zipPassword;
}
const result = await uploadBill(selectedFile, selectedType, options);
if (result.result) {
uploadResult = result;
} else {
errorMessage = result.message || '上传失败';
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '网络错误';
} finally {
isUploading = false;
}
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
</script>
<svelte:head>
<title>上传账单 - BillAI</title>
</svelte:head>
<div class="space-y-6">
<!-- 页面标题 -->
<div>
<h1 class="text-2xl font-bold tracking-tight">账单管理</h1>
<p class="text-muted-foreground">上传并分析您的支付宝、微信、京东账单</p>
</div>
<!-- 统计卡片 -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each stats as stat}
<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">{stat.title}</Card.Title>
{#if stat.trend === 'up'}
<TrendingUp class="h-4 w-4 text-green-500" />
{:else}
<TrendingDown class="h-4 w-4 text-red-500" />
{/if}
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{stat.value}</div>
<p class="text-xs text-muted-foreground">
<span class={stat.trend === 'up' ? 'text-green-500' : 'text-red-500'}>
{stat.change}
</span>
{' '}{stat.description}
</p>
</Card.Content>
</Card.Root>
{/each}
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- 上传区域 -->
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0">
<div>
<Card.Title>上传账单</Card.Title>
<Card.Description>支持支付宝、微信、京东账单 CSV、XLSX 或 ZIP 文件</Card.Description>
</div>
<Button variant="outline" size="sm" onclick={() => goto('/bills?tab=manual')}>
<Plus class="mr-2 h-4 w-4" />
手动添加
</Button>
</Card.Header>
<Card.Content class="space-y-4">
<!-- 拖拽上传区域 -->
<div
class="relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer
{isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50'}"
role="button"
tabindex="0"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => document.getElementById('file-input')?.click()}
onkeydown={(e) => e.key === 'Enter' && document.getElementById('file-input')?.click()}
>
<input
type="file"
id="file-input"
accept=".csv,.xlsx,.zip"
onchange={handleFileSelect}
hidden
/>
{#if selectedFile}
<div class="flex items-center justify-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<FileText class="h-6 w-6 text-primary" />
</div>
<div class="text-left">
<p class="font-medium">{selectedFile.name}</p>
<p class="text-sm text-muted-foreground">{formatFileSize(selectedFile.size)}</p>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto"
onclick={(e) => { e.stopPropagation(); clearFile(); }}
>
<X class="h-4 w-4" />
</Button>
</div>
{:else}
<div class="flex flex-col items-center gap-3">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<Upload class="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p class="font-medium">
{isDragOver ? '松开鼠标上传文件' : '拖拽文件到这里,或点击选择'}
</p>
<p class="text-sm text-muted-foreground">支持 .csv、.xlsx、.zip 格式</p>
</div>
</div>
{/if}
</div>
<!-- 错误提示 -->
{#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}
<!-- ZIP 密码输入 -->
{#if isZipFile}
<div class="flex items-center gap-3">
<span class="text-sm font-medium">ZIP 密码:</span>
<input
type="password"
bind:value={zipPassword}
placeholder="如有密码请输入"
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
{/if}
<!-- 账单类型选择 -->
<div class="flex items-center gap-3">
<span class="text-sm font-medium">账单类型:</span>
<div class="flex gap-2">
<Button
variant={selectedType === 'alipay' ? 'default' : 'outline'}
size="sm"
onclick={() => selectedType = 'alipay'}
>
支付宝
</Button>
<Button
variant={selectedType === 'wechat' ? 'default' : 'outline'}
size="sm"
onclick={() => selectedType = 'wechat'}
>
微信
</Button>
<Button
variant={selectedType === 'jd' ? 'default' : 'outline'}
size="sm"
onclick={() => selectedType = 'jd'}
>
京东
</Button>
</div>
</div>
<!-- 上传按钮 -->
<Button
class="w-full"
disabled={!selectedFile || isUploading}
onclick={handleUpload}
>
{#if isUploading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
处理中...
{:else}
<Upload class="mr-2 h-4 w-4" />
开始处理
{/if}
</Button>
</Card.Content>
</Card.Root>
<!-- 处理结果 -->
<Card.Root>
<Card.Header>
<Card.Title>处理结果</Card.Title>
<Card.Description>账单分析完成后将显示在这里</Card.Description>
</Card.Header>
<Card.Content>
{#if uploadResult?.result}
<div class="space-y-4">
<div class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-green-950">
<CheckCircle class="h-5 w-5 text-green-600 dark:text-green-400" />
<div>
<p class="font-medium text-green-800 dark:text-green-200">处理成功</p>
<p class="text-sm text-green-600 dark:text-green-400">{uploadResult.message}</p>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">账单类型</span>
<Badge variant="secondary">
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : uploadResult.data?.bill_type === 'wechat' ? '微信' : '京东'}
</Badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">原始记录数</span>
<span class="text-sm font-medium">{uploadResult.data?.raw_count ?? 0}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">清洗后记录数</span>
<span class="text-sm font-medium">{uploadResult.data?.cleaned_count ?? 0}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">输出文件</span>
<span class="text-sm font-medium">{uploadResult.data?.file_name}</span>
</div>
</div>
<div class="flex gap-3 pt-2">
<a
href={uploadResult.data?.file_url || '#'}
download
class="flex-1"
>
<Button variant="outline" class="w-full">
<Download class="mr-2 h-4 w-4" />
下载结果
</Button>
</a>
<a
href={`/review?file=${encodeURIComponent(uploadResult.data?.file_name || '')}`}
class="flex-1"
>
<Button class="w-full">
<ClipboardCheck class="mr-2 h-4 w-4" />
查看复核
</Button>
</a>
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<FileText class="h-8 w-8 text-muted-foreground" />
</div>
<p class="text-muted-foreground">暂无处理结果</p>
<p class="text-sm text-muted-foreground">上传账单后将显示分析结果</p>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
</div>