refactor(web): unify bills as UIBill, remove BillRecord
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
|
||||
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
|
||||
const API_BASE = '';
|
||||
@@ -95,19 +96,6 @@ export interface MonthlyStatsResponse {
|
||||
data?: MonthlyStat[];
|
||||
}
|
||||
|
||||
export interface BillRecord {
|
||||
time: string;
|
||||
category: string;
|
||||
merchant: string;
|
||||
description: string;
|
||||
income_expense: string;
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
remark: string;
|
||||
needs_review: string;
|
||||
}
|
||||
|
||||
// 上传账单
|
||||
export async function uploadBill(
|
||||
file: File,
|
||||
@@ -165,7 +153,7 @@ export function getDownloadUrl(fileUrl: string): string {
|
||||
}
|
||||
|
||||
// 解析账单内容(用于前端展示全部记录)
|
||||
export async function fetchBillContent(fileName: string): Promise<BillRecord[]> {
|
||||
export async function fetchBillContent(fileName: string): Promise<UIBill[]> {
|
||||
const response = await apiFetch(`${API_BASE}/download/${fileName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -177,11 +165,11 @@ export async function fetchBillContent(fileName: string): Promise<BillRecord[]>
|
||||
}
|
||||
|
||||
// 解析 CSV
|
||||
function parseCSV(text: string): BillRecord[] {
|
||||
function parseCSV(text: string): UIBill[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const records: BillRecord[] = [];
|
||||
const records: UIBill[] = [];
|
||||
|
||||
// CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
@@ -191,13 +179,13 @@ function parseCSV(text: string): BillRecord[] {
|
||||
time: values[0] || '',
|
||||
category: values[1] || '',
|
||||
merchant: values[2] || '',
|
||||
description: values[4] || '', // 跳过 values[3] (对方账号)
|
||||
income_expense: values[5] || '',
|
||||
amount: values[6] || '',
|
||||
payment_method: values[7] || '',
|
||||
description: values[4] || '', // 跳过 values[3] (对方账号)
|
||||
incomeExpense: values[5] || '',
|
||||
amount: Number(values[6] || 0),
|
||||
paymentMethod: values[7] || '',
|
||||
status: values[8] || '',
|
||||
remark: values[11] || '',
|
||||
needs_review: values[13] || '', // 复核等级在第14列
|
||||
reviewLevel: values[13] || '', // 复核等级在第14列
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
277
web/src/lib/components/analysis/BillDetailDrawer.svelte
Normal file
277
web/src/lib/components/analysis/BillDetailDrawer.svelte
Normal file
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import Receipt from '@lucide/svelte/icons/receipt';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Store from '@lucide/svelte/icons/store';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import FileText from '@lucide/svelte/icons/file-text';
|
||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||
|
||||
import { updateBill } from '$lib/api';
|
||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
record?: UIBill | null;
|
||||
categories?: string[];
|
||||
|
||||
title?: string;
|
||||
viewDescription?: string;
|
||||
editDescription?: string;
|
||||
|
||||
titleExtra?: Snippet<[{ isEditing: boolean }]>;
|
||||
|
||||
contentClass?: string;
|
||||
|
||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
record = $bindable<UIBill | null>(null),
|
||||
categories = [],
|
||||
title = '账单详情',
|
||||
viewDescription = '查看这笔支出的完整信息',
|
||||
editDescription = '修改这笔支出的信息',
|
||||
titleExtra,
|
||||
contentClass,
|
||||
onUpdate
|
||||
}: Props = $props();
|
||||
|
||||
let isEditing = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
let editForm = $state({
|
||||
amount: '',
|
||||
merchant: '',
|
||||
category: '',
|
||||
description: '',
|
||||
payment_method: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
isEditing = false;
|
||||
});
|
||||
|
||||
function startEdit() {
|
||||
if (!record) return;
|
||||
editForm = {
|
||||
amount: String(record.amount),
|
||||
merchant: record.merchant,
|
||||
category: record.category,
|
||||
description: record.description || '',
|
||||
payment_method: record.paymentMethod || ''
|
||||
};
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function handleCategoryChange(value: string | undefined) {
|
||||
if (value) editForm.category = value;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!record) return;
|
||||
if (isSaving) return;
|
||||
|
||||
isSaving = true;
|
||||
const original = { ...record };
|
||||
|
||||
const updated: UIBill = {
|
||||
...record,
|
||||
amount: Number(editForm.amount),
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
description: editForm.description,
|
||||
paymentMethod: editForm.payment_method
|
||||
};
|
||||
|
||||
try {
|
||||
const billId = (record as unknown as { id?: string }).id;
|
||||
if (billId) {
|
||||
const resp = await updateBill(billId, {
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
amount: Number(editForm.amount),
|
||||
description: editForm.description,
|
||||
pay_method: editForm.payment_method
|
||||
});
|
||||
|
||||
if (resp.result && resp.data) {
|
||||
const persisted = cleanedBillToUIBill(resp.data);
|
||||
updated.id = persisted.id;
|
||||
updated.amount = persisted.amount;
|
||||
updated.merchant = persisted.merchant;
|
||||
updated.category = persisted.category;
|
||||
updated.description = persisted.description;
|
||||
updated.paymentMethod = persisted.paymentMethod;
|
||||
updated.time = persisted.time;
|
||||
updated.incomeExpense = persisted.incomeExpense;
|
||||
updated.status = persisted.status;
|
||||
updated.remark = persisted.remark;
|
||||
updated.reviewLevel = persisted.reviewLevel;
|
||||
}
|
||||
}
|
||||
|
||||
record = updated;
|
||||
isEditing = false;
|
||||
onUpdate?.(updated, original);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer.Root bind:open>
|
||||
<Drawer.Content class={`md:max-w-4xl ${contentClass ?? ''}`.trim()}>
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : title}
|
||||
{@render titleExtra?.({ isEditing })}
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{isEditing ? editDescription : viewDescription}
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto px-4 py-4 md:px-0">
|
||||
{#if record}
|
||||
{#if isEditing}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>金额</Label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
||||
<Input type="number" bind:value={editForm.amount} class="pl-8" step="0.01" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>商家</Label>
|
||||
<Input bind:value={editForm.merchant} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>分类</Label>
|
||||
{#if categories.length > 0}
|
||||
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span>{editForm.category || '选择分类'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content>
|
||||
{#each categories as category}
|
||||
<Select.Item value={category}>{category}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<Input bind:value={editForm.category} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input bind:value={editForm.description} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>支付方式</Label>
|
||||
<Input bind:value={editForm.payment_method} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">¥{record.amount.toFixed(2)}</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">支出金额</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Store class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">商家</div>
|
||||
<div class="font-medium truncate">{record.merchant}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Tag class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">分类</div>
|
||||
<div class="font-medium">{record.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Calendar class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">时间</div>
|
||||
<div class="font-medium">{record.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if record.description}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<FileText class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">描述</div>
|
||||
<div class="font-medium">{record.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if record.paymentMethod}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<CreditCard class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">支付方式</div>
|
||||
<div class="font-medium">{record.paymentMethod}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Drawer.Footer>
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit} disabled={isSaving}>
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{isSaving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={() => (open = false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onclick={startEdit}>
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
@@ -1,33 +1,21 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
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 Receipt from '@lucide/svelte/icons/receipt';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Store from '@lucide/svelte/icons/store';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import FileText from '@lucide/svelte/icons/file-text';
|
||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||
import { updateBill, type BillRecord } from '$lib/api';
|
||||
import { type UIBill } from '$lib/models/bill';
|
||||
import BillDetailDrawer from './BillDetailDrawer.svelte';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
showCategory?: boolean;
|
||||
showDescription?: boolean;
|
||||
pageSize?: number;
|
||||
categories?: string[];
|
||||
onUpdate?: (updated: BillRecord, original: BillRecord) => void;
|
||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -50,17 +38,7 @@
|
||||
|
||||
// 详情弹窗状态
|
||||
let detailDialogOpen = $state(false);
|
||||
let selectedRecord = $state<BillRecord | null>(null);
|
||||
let selectedIndex = $state(-1);
|
||||
let isEditing = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let editForm = $state({
|
||||
amount: '',
|
||||
merchant: '',
|
||||
category: '',
|
||||
description: '',
|
||||
payment_method: ''
|
||||
});
|
||||
let selectedRecord = $state<UIBill | null>(null);
|
||||
|
||||
// 排序后的记录
|
||||
let sortedRecords = $derived.by(() => {
|
||||
@@ -80,7 +58,7 @@
|
||||
cmp = (a.description || '').localeCompare(b.description || '');
|
||||
break;
|
||||
case 'amount':
|
||||
cmp = parseFloat(a.amount) - parseFloat(b.amount);
|
||||
cmp = (a.amount || 0) - (b.amount || 0);
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
@@ -110,100 +88,28 @@
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
function openDetail(record: BillRecord, index: number) {
|
||||
function openDetail(record: UIBill) {
|
||||
selectedRecord = record;
|
||||
selectedIndex = index;
|
||||
isEditing = false;
|
||||
detailDialogOpen = true;
|
||||
}
|
||||
|
||||
// 进入编辑模式
|
||||
function startEdit() {
|
||||
if (!selectedRecord) return;
|
||||
editForm = {
|
||||
amount: selectedRecord.amount,
|
||||
merchant: selectedRecord.merchant,
|
||||
category: selectedRecord.category,
|
||||
description: selectedRecord.description || '',
|
||||
payment_method: selectedRecord.payment_method || ''
|
||||
};
|
||||
isEditing = 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
|
||||
);
|
||||
|
||||
// 取消编辑
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
async function saveEdit() {
|
||||
if (!selectedRecord) return;
|
||||
|
||||
if (isSaving) return;
|
||||
isSaving = true;
|
||||
|
||||
const original = { ...selectedRecord };
|
||||
const updated: BillRecord = {
|
||||
...selectedRecord,
|
||||
amount: editForm.amount,
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
description: editForm.description,
|
||||
payment_method: editForm.payment_method
|
||||
};
|
||||
|
||||
try {
|
||||
// 如果有后端 ID,则持久化更新
|
||||
const billId = (selectedRecord as unknown as { id?: string }).id;
|
||||
if (billId) {
|
||||
const resp = await updateBill(billId, {
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
amount: Number(editForm.amount),
|
||||
description: editForm.description,
|
||||
pay_method: editForm.payment_method,
|
||||
});
|
||||
|
||||
if (resp.result && resp.data) {
|
||||
// 将后端返回的 CleanedBill 映射为 BillRecord
|
||||
updated.amount = String(resp.data.amount);
|
||||
updated.merchant = resp.data.merchant;
|
||||
updated.category = resp.data.category;
|
||||
updated.description = resp.data.description || '';
|
||||
updated.payment_method = resp.data.pay_method || '';
|
||||
// 让时间展示更稳定:使用后端格式
|
||||
updated.time = resp.data.time;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新本地数据(fallback:按引用/关键字段查找)
|
||||
const idx = records.findIndex(r => r === selectedRecord);
|
||||
const finalIdx = idx !== -1
|
||||
? idx
|
||||
: records.findIndex(r =>
|
||||
r.time === selectedRecord!.time &&
|
||||
r.merchant === selectedRecord!.merchant &&
|
||||
r.amount === selectedRecord!.amount
|
||||
);
|
||||
|
||||
if (finalIdx !== -1) {
|
||||
records[finalIdx] = updated;
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
selectedRecord = updated;
|
||||
isEditing = false;
|
||||
onUpdate?.(updated, original);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
if (finalIdx !== -1) {
|
||||
records[finalIdx] = updated;
|
||||
records = [...records];
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分类选择
|
||||
function handleCategoryChange(value: string | undefined) {
|
||||
if (value) {
|
||||
editForm.category = value;
|
||||
}
|
||||
onUpdate?.(updated, original);
|
||||
}
|
||||
|
||||
// 重置分页(当记录变化时)
|
||||
@@ -294,7 +200,7 @@
|
||||
{#each paginatedRecords as record, i}
|
||||
<Table.Row
|
||||
class="hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onclick={() => openDetail(record, (currentPage - 1) * pageSize + i)}
|
||||
onclick={() => openDetail(record)}
|
||||
>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{record.time.substring(0, 16)}
|
||||
@@ -309,7 +215,7 @@
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
¥{record.amount.toFixed(2)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
@@ -366,151 +272,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 详情/编辑弹窗 -->
|
||||
<Drawer.Root bind:open={detailDialogOpen}>
|
||||
<Drawer.Content class="md:max-w-md">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : '账单详情'}
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
{#if selectedRecord}
|
||||
{#if isEditing}
|
||||
<!-- 编辑表单 -->
|
||||
<div class="space-y-4 py-4 px-4 md:px-0">
|
||||
<div class="space-y-2">
|
||||
<Label>金额</Label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={editForm.amount}
|
||||
class="pl-8"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>商家</Label>
|
||||
<Input bind:value={editForm.merchant} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>分类</Label>
|
||||
{#if categories.length > 0}
|
||||
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span>{editForm.category || '选择分类'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content>
|
||||
{#each categories as category}
|
||||
<Select.Item value={category}>{category}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<Input bind:value={editForm.category} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input bind:value={editForm.description} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>支付方式</Label>
|
||||
<Input bind:value={editForm.payment_method} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 详情展示 -->
|
||||
<div class="py-4 px-4 md:px-0">
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
|
||||
¥{selectedRecord.amount}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
支出金额
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Store class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">商家</div>
|
||||
<div class="font-medium truncate">{selectedRecord.merchant}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Tag class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">分类</div>
|
||||
<div class="font-medium">{selectedRecord.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Calendar class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">时间</div>
|
||||
<div class="font-medium">{selectedRecord.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedRecord.description}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<FileText class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">描述</div>
|
||||
<div class="font-medium">{selectedRecord.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedRecord.payment_method}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<CreditCard class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">支付方式</div>
|
||||
<div class="font-medium">{selectedRecord.payment_method}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Drawer.Footer>
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit} disabled={isSaving}>
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{isSaving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={() => detailDialogOpen = false}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onclick={startEdit}>
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
<BillDetailDrawer
|
||||
bind:open={detailDialogOpen}
|
||||
bind:record={selectedRecord}
|
||||
{categories}
|
||||
title="账单详情"
|
||||
viewDescription="查看这笔支出的详细信息"
|
||||
editDescription="修改这笔支出的信息"
|
||||
onUpdate={handleRecordUpdated}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
import { getPercentage } from '$lib/services/analysis';
|
||||
import { barColors } from '$lib/constants/chart';
|
||||
import BillRecordsTable from './BillRecordsTable.svelte';
|
||||
@@ -14,7 +14,7 @@
|
||||
categoryStats: CategoryStat[];
|
||||
pieChartData: PieChartDataItem[];
|
||||
totalExpense: number;
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
// 获取选中分类的账单记录
|
||||
let selectedRecords = $derived.by(() => {
|
||||
if (!selectedCategory) return [];
|
||||
return records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
|
||||
return records.filter(r => r.category === selectedCategory && r.incomeExpense === '支出');
|
||||
});
|
||||
|
||||
// 选中分类的统计
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
import AreaChart from '@lucide/svelte/icons/area-chart';
|
||||
import LineChart from '@lucide/svelte/icons/line-chart';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
import { formatLocalDate } from '$lib/utils';
|
||||
import BillRecordsTable from './BillRecordsTable.svelte';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
// Dialog 状态
|
||||
let dialogOpen = $state(false);
|
||||
let selectedDate = $state<Date | null>(null);
|
||||
let selectedDateRecords = $state<BillRecord[]>([]);
|
||||
let selectedDateRecords = $state<UIBill[]>([]);
|
||||
|
||||
// 时间范围选项
|
||||
type TimeRange = '7d' | 'week' | '30d' | 'month' | '3m' | 'year';
|
||||
@@ -125,7 +125,7 @@
|
||||
|
||||
// 过滤支出记录
|
||||
const expenseRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
if (r.incomeExpense !== '支出') return false;
|
||||
const recordDate = new Date(extractDateStr(r.time));
|
||||
return recordDate >= cutoffDate;
|
||||
});
|
||||
@@ -139,7 +139,7 @@
|
||||
expenseRecords.forEach(record => {
|
||||
const dateStr = extractDateStr(record.time);
|
||||
const category = record.category || '其他';
|
||||
const amount = parseFloat(record.amount) || 0;
|
||||
const amount = record.amount || 0;
|
||||
|
||||
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
|
||||
|
||||
@@ -502,6 +502,19 @@
|
||||
tooltipData = null;
|
||||
}
|
||||
|
||||
function openDateDetails(clickedDate: Date) {
|
||||
const dateStr = formatLocalDate(clickedDate);
|
||||
|
||||
selectedDate = clickedDate;
|
||||
selectedDateRecords = records.filter(r => {
|
||||
if (r.incomeExpense !== '支出') return false;
|
||||
const recordDateStr = extractDateStr(r.time);
|
||||
return recordDateStr === dateStr;
|
||||
});
|
||||
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
// 点击打开 Dialog
|
||||
function handleClick(event: MouseEvent, data: any[], maxValue: number) {
|
||||
if (data.length === 0) return;
|
||||
@@ -527,17 +540,7 @@
|
||||
|
||||
// 点击图表任意位置都触发,选择最近的日期
|
||||
const clickedDate = data[closestIdx].date;
|
||||
const dateStr = formatLocalDate(clickedDate);
|
||||
|
||||
// 找出当天的所有支出记录
|
||||
selectedDate = clickedDate;
|
||||
selectedDateRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
const recordDateStr = extractDateStr(r.time);
|
||||
return recordDateStr === dateStr;
|
||||
});
|
||||
|
||||
dialogOpen = true;
|
||||
openDateDetails(clickedDate);
|
||||
}
|
||||
|
||||
// 计算选中日期的统计
|
||||
@@ -549,7 +552,7 @@
|
||||
|
||||
selectedDateRecords.forEach(r => {
|
||||
const cat = r.category || '其他';
|
||||
const amount = parseFloat(r.amount) || 0;
|
||||
const amount = r.amount || 0;
|
||||
total += amount;
|
||||
|
||||
if (!categoryMap.has(cat)) {
|
||||
@@ -648,16 +651,22 @@
|
||||
|
||||
<!-- 趋势图 (自定义 SVG) -->
|
||||
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<svg
|
||||
viewBox="0 0 {chartWidth} {chartHeight}"
|
||||
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
||||
role="application"
|
||||
role="button"
|
||||
aria-label="每日支出趋势图表,点击可查看当日详情"
|
||||
tabindex="-1"
|
||||
tabindex="0"
|
||||
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
|
||||
onmouseleave={handleMouseLeave}
|
||||
onclick={(e) => handleClick(e, data, maxValue)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const last = data[data.length - 1];
|
||||
if (last?.date) openDateDetails(last.date);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- Y 轴 -->
|
||||
<line
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
import Wallet from '@lucide/svelte/icons/wallet';
|
||||
import type { TotalStats } from '$lib/types/analysis';
|
||||
import { countByType } from '$lib/services/analysis';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
|
||||
interface Props {
|
||||
totalStats: TotalStats;
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
}
|
||||
|
||||
let { totalStats, records }: Props = $props();
|
||||
|
||||
@@ -1,125 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import Flame from '@lucide/svelte/icons/flame';
|
||||
import Receipt from '@lucide/svelte/icons/receipt';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Store from '@lucide/svelte/icons/store';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import FileText from '@lucide/svelte/icons/file-text';
|
||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||
import { updateBill, type BillRecord } from '$lib/api';
|
||||
import { type UIBill } from '$lib/models/bill';
|
||||
import BillDetailDrawer from './BillDetailDrawer.svelte';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
categories: string[]; // 可用的分类列表
|
||||
onUpdate?: (record: BillRecord) => void;
|
||||
onUpdate?: (record: UIBill) => void;
|
||||
}
|
||||
|
||||
let { records, categories, onUpdate }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let selectedRecord = $state<BillRecord | null>(null);
|
||||
let selectedRecord = $state<UIBill | null>(null);
|
||||
let selectedRank = $state(0);
|
||||
let isEditing = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
// 编辑表单数据
|
||||
let editForm = $state({
|
||||
merchant: '',
|
||||
category: '',
|
||||
amount: '',
|
||||
description: '',
|
||||
payment_method: ''
|
||||
});
|
||||
|
||||
function openDetail(record: BillRecord, rank: number) {
|
||||
function openDetail(record: UIBill, rank: number) {
|
||||
selectedRecord = record;
|
||||
selectedRank = rank;
|
||||
isEditing = false;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!selectedRecord) return;
|
||||
editForm = {
|
||||
merchant: selectedRecord.merchant,
|
||||
category: selectedRecord.category,
|
||||
amount: selectedRecord.amount,
|
||||
description: selectedRecord.description || '',
|
||||
payment_method: selectedRecord.payment_method || ''
|
||||
};
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!selectedRecord) return;
|
||||
|
||||
if (isSaving) return;
|
||||
isSaving = true;
|
||||
|
||||
// 更新记录
|
||||
const updatedRecord: BillRecord = {
|
||||
...selectedRecord,
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
amount: editForm.amount,
|
||||
description: editForm.description,
|
||||
payment_method: editForm.payment_method
|
||||
};
|
||||
|
||||
try {
|
||||
const billId = (selectedRecord as unknown as { id?: string }).id;
|
||||
if (billId) {
|
||||
const resp = await updateBill(billId, {
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
amount: Number(editForm.amount),
|
||||
description: editForm.description,
|
||||
pay_method: editForm.payment_method,
|
||||
});
|
||||
|
||||
if (resp.result && resp.data) {
|
||||
updatedRecord.amount = String(resp.data.amount);
|
||||
updatedRecord.merchant = resp.data.merchant;
|
||||
updatedRecord.category = resp.data.category;
|
||||
updatedRecord.description = resp.data.description || '';
|
||||
updatedRecord.payment_method = resp.data.pay_method || '';
|
||||
updatedRecord.time = resp.data.time;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新本地数据
|
||||
const index = records.findIndex(r => r === selectedRecord);
|
||||
if (index !== -1) {
|
||||
records[index] = updatedRecord;
|
||||
}
|
||||
|
||||
selectedRecord = updatedRecord;
|
||||
isEditing = false;
|
||||
|
||||
// 通知父组件
|
||||
onUpdate?.(updatedRecord);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCategoryChange(value: string | undefined) {
|
||||
if (value) {
|
||||
editForm.category = value;
|
||||
}
|
||||
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||
const idx = records.findIndex(r => r === original);
|
||||
if (idx !== -1) records[idx] = updated;
|
||||
selectedRecord = updated;
|
||||
onUpdate?.(updated);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -153,7 +60,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="font-mono font-bold text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
¥{record.amount.toFixed(2)}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -161,157 +68,24 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- 账单详情弹窗 -->
|
||||
<Drawer.Root bind:open={dialogOpen}>
|
||||
<Drawer.Content class="md:max-w-[450px]">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : '账单详情'}
|
||||
{#if selectedRank <= 3 && !isEditing}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {
|
||||
selectedRank === 1 ? 'bg-gradient-to-r from-yellow-400 to-amber-500 text-white' :
|
||||
selectedRank === 2 ? 'bg-gradient-to-r from-slate-300 to-slate-400 text-white' :
|
||||
'bg-gradient-to-r from-orange-400 to-amber-600 text-white'
|
||||
}">
|
||||
Top {selectedRank}
|
||||
</span>
|
||||
{/if}
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
{#if selectedRecord}
|
||||
{#if isEditing}
|
||||
<!-- 编辑模式 -->
|
||||
<div class="py-4 space-y-4 px-4 md:px-0">
|
||||
<div class="space-y-2">
|
||||
<Label for="amount">金额</Label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={editForm.amount}
|
||||
class="pl-7 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="merchant">商家</Label>
|
||||
<Input id="merchant" bind:value={editForm.merchant} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>分类</Label>
|
||||
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span>{editForm.category || '选择分类'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content>
|
||||
{#each categories as category}
|
||||
<Select.Item value={category}>{category}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="description">描述</Label>
|
||||
<Input id="description" bind:value={editForm.description} placeholder="可选" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="payment_method">支付方式</Label>
|
||||
<Input id="payment_method" bind:value={editForm.payment_method} placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 查看模式 -->
|
||||
<div class="py-4 space-y-4 px-4 md:px-0">
|
||||
<!-- 金额 -->
|
||||
<div class="text-center py-4 bg-red-50 dark:bg-red-950/30 rounded-lg">
|
||||
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
|
||||
<p class="text-3xl font-bold font-mono text-red-600 dark:text-red-400">
|
||||
¥{selectedRecord.amount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 详情列表 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Store class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">商家</p>
|
||||
<p class="font-medium">{selectedRecord.merchant}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Tag class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">分类</p>
|
||||
<p class="font-medium">{selectedRecord.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Calendar class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">时间</p>
|
||||
<p class="font-medium">{selectedRecord.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedRecord.description}
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<FileText class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">描述</p>
|
||||
<p class="font-medium">{selectedRecord.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedRecord.payment_method}
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<CreditCard class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">支付方式</p>
|
||||
<p class="font-medium">{selectedRecord.payment_method}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<BillDetailDrawer
|
||||
bind:open={dialogOpen}
|
||||
bind:record={selectedRecord}
|
||||
{categories}
|
||||
title="账单详情"
|
||||
viewDescription="查看这笔支出的完整信息"
|
||||
editDescription="修改这笔支出的信息"
|
||||
onUpdate={handleRecordUpdated}
|
||||
>
|
||||
{#snippet titleExtra({ isEditing })}
|
||||
{#if selectedRank <= 3 && !isEditing}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {
|
||||
selectedRank === 1 ? 'bg-gradient-to-r from-yellow-400 to-amber-500 text-white' :
|
||||
selectedRank === 2 ? 'bg-gradient-to-r from-slate-300 to-slate-400 text-white' :
|
||||
'bg-gradient-to-r from-orange-400 to-amber-600 text-white'
|
||||
}">
|
||||
Top {selectedRank}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<Drawer.Footer class="flex gap-2">
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit} disabled={isSaving}>
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{isSaving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={() => dialogOpen = false}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onclick={startEdit}>
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
{/snippet}
|
||||
</BillDetailDrawer>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
config: ChartConfig;
|
||||
} = $props();
|
||||
|
||||
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
|
||||
let chartId = $derived.by(() => `chart-${id || uid.replace(/:/g, "")}`);
|
||||
|
||||
setChartContext({
|
||||
get config() {
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
|
||||
type DemoBillRow = {
|
||||
time: string;
|
||||
category: string;
|
||||
merchant: string;
|
||||
description: string;
|
||||
income_expense: string;
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
remark: string;
|
||||
needs_review: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 真实账单数据(来自支付宝和微信导出)
|
||||
* 数据已脱敏处理
|
||||
*/
|
||||
export const demoRecords: BillRecord[] = [
|
||||
const demoRows: DemoBillRow[] = [
|
||||
// ========== 支付宝数据 ==========
|
||||
{ time: "2026-01-07 12:01:02", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||
{ time: "2026-01-06 15:54:53", category: "餐饮美食", merchant: "友宝", description: "智能货柜消费", income_expense: "支出", amount: "7.19", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||
@@ -167,3 +180,16 @@ export const demoRecords: BillRecord[] = [
|
||||
{ time: "2025-12-08 19:15:45", category: "餐饮美食", merchant: "瑞幸咖啡", description: "咖啡", income_expense: "支出", amount: "12.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||
{ time: "2025-12-07 18:42:19", category: "餐饮美食", merchant: "奶茶店", description: "饮品", income_expense: "支出", amount: "15.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||
].sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
||||
|
||||
export const demoRecords: UIBill[] = demoRows.map((r) => ({
|
||||
time: r.time,
|
||||
category: r.category,
|
||||
merchant: r.merchant,
|
||||
description: r.description || '',
|
||||
incomeExpense: r.income_expense,
|
||||
amount: Number(r.amount || 0),
|
||||
paymentMethod: r.payment_method || '',
|
||||
status: r.status || '',
|
||||
remark: r.remark || '',
|
||||
reviewLevel: r.needs_review || '',
|
||||
}));
|
||||
|
||||
45
web/src/lib/models/bill.ts
Normal file
45
web/src/lib/models/bill.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { CleanedBill, UpdateBillRequest } from '$lib/api';
|
||||
|
||||
export interface UIBill {
|
||||
id?: string;
|
||||
time: string;
|
||||
category: string;
|
||||
merchant: string;
|
||||
description?: string;
|
||||
incomeExpense: string;
|
||||
amount: number;
|
||||
paymentMethod?: string;
|
||||
status?: string;
|
||||
remark?: string;
|
||||
reviewLevel?: string;
|
||||
}
|
||||
|
||||
export function cleanedBillToUIBill(bill: CleanedBill): UIBill {
|
||||
return {
|
||||
id: bill.id,
|
||||
time: bill.time,
|
||||
category: bill.category,
|
||||
merchant: bill.merchant,
|
||||
description: bill.description || '',
|
||||
incomeExpense: bill.income_expense,
|
||||
amount: bill.amount,
|
||||
paymentMethod: bill.pay_method || '',
|
||||
status: bill.status || '',
|
||||
remark: bill.remark || '',
|
||||
reviewLevel: bill.review_level || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function uiBillToUpdateBillRequest(bill: UIBill): UpdateBillRequest {
|
||||
return {
|
||||
time: bill.time,
|
||||
category: bill.category,
|
||||
merchant: bill.merchant,
|
||||
description: bill.description,
|
||||
income_expense: bill.incomeExpense,
|
||||
amount: bill.amount,
|
||||
pay_method: bill.paymentMethod,
|
||||
status: bill.status,
|
||||
remark: bill.remark,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
import type { CategoryStat, MonthlyStat, DailyExpenseData, TotalStats, PieChartDataItem } from '$lib/types/analysis';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
|
||||
/**
|
||||
* 计算分类统计
|
||||
*/
|
||||
export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
||||
export function calculateCategoryStats(records: UIBill[]): CategoryStat[] {
|
||||
const stats = new Map<string, { expense: number; income: number; count: number }>();
|
||||
|
||||
for (const r of records) {
|
||||
@@ -14,8 +14,8 @@ export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
||||
}
|
||||
const s = stats.get(r.category)!;
|
||||
s.count++;
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
if (r.income_expense === '支出') {
|
||||
const amount = r.amount || 0;
|
||||
if (r.incomeExpense === '支出') {
|
||||
s.expense += amount;
|
||||
} else {
|
||||
s.income += amount;
|
||||
@@ -30,7 +30,7 @@ export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
||||
/**
|
||||
* 计算月度统计
|
||||
*/
|
||||
export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
||||
export function calculateMonthlyStats(records: UIBill[]): MonthlyStat[] {
|
||||
const stats = new Map<string, { expense: number; income: number }>();
|
||||
|
||||
for (const r of records) {
|
||||
@@ -39,8 +39,8 @@ export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
||||
stats.set(month, { expense: 0, income: 0 });
|
||||
}
|
||||
const s = stats.get(month)!;
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
if (r.income_expense === '支出') {
|
||||
const amount = r.amount || 0;
|
||||
if (r.incomeExpense === '支出') {
|
||||
s.expense += amount;
|
||||
} else {
|
||||
s.income += amount;
|
||||
@@ -55,13 +55,13 @@ export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
||||
/**
|
||||
* 计算每日支出数据(用于面积图)
|
||||
*/
|
||||
export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseData[] {
|
||||
export function calculateDailyExpenseData(records: UIBill[]): DailyExpenseData[] {
|
||||
const stats = new Map<string, number>();
|
||||
|
||||
for (const r of records) {
|
||||
if (r.income_expense !== '支出') continue;
|
||||
if (r.incomeExpense !== '支出') continue;
|
||||
const date = r.time.substring(0, 10); // YYYY-MM-DD
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
const amount = r.amount || 0;
|
||||
stats.set(date, (stats.get(date) || 0) + amount);
|
||||
}
|
||||
|
||||
@@ -73,14 +73,14 @@ export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseDa
|
||||
/**
|
||||
* 计算总计统计
|
||||
*/
|
||||
export function calculateTotalStats(records: BillRecord[]): TotalStats {
|
||||
export function calculateTotalStats(records: UIBill[]): TotalStats {
|
||||
return {
|
||||
expense: records
|
||||
.filter(r => r.income_expense === '支出')
|
||||
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
|
||||
.filter(r => r.incomeExpense === '支出')
|
||||
.reduce((sum, r) => sum + (r.amount || 0), 0),
|
||||
income: records
|
||||
.filter(r => r.income_expense === '收入')
|
||||
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
|
||||
.filter(r => r.incomeExpense === '收入')
|
||||
.reduce((sum, r) => sum + (r.amount || 0), 0),
|
||||
count: records.length,
|
||||
};
|
||||
}
|
||||
@@ -112,18 +112,18 @@ export function calculatePieChartData(
|
||||
/**
|
||||
* 获取 Top N 支出记录
|
||||
*/
|
||||
export function getTopExpenses(records: BillRecord[], n: number = 10): BillRecord[] {
|
||||
export function getTopExpenses(records: UIBill[], n: number = 10): UIBill[] {
|
||||
return records
|
||||
.filter(r => r.income_expense === '支出')
|
||||
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
|
||||
.filter(r => r.incomeExpense === '支出')
|
||||
.sort((a, b) => (b.amount || 0) - (a.amount || 0))
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计支出/收入笔数
|
||||
*/
|
||||
export function countByType(records: BillRecord[], type: '支出' | '收入'): number {
|
||||
return records.filter(r => r.income_expense === type).length;
|
||||
export function countByType(records: UIBill[], type: '支出' | '收入'): number {
|
||||
return records.filter(r => r.incomeExpense === type).length;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
|
||||
/** 分类统计数据 */
|
||||
export interface CategoryStat {
|
||||
@@ -47,7 +47,7 @@ export interface AnalysisState {
|
||||
fileName: string;
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
isDemo: boolean;
|
||||
categoryChartMode: 'bar' | 'pie';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchBills, fetchMonthlyStats, checkHealth, type CleanedBill, type MonthlyStat } from '$lib/api';
|
||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -74,25 +75,9 @@
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 将 CleanedBill 转换为分析服务需要的格式
|
||||
function toAnalysisRecords(bills: CleanedBill[]) {
|
||||
return bills.map(bill => ({
|
||||
time: bill.time,
|
||||
category: bill.category,
|
||||
merchant: bill.merchant,
|
||||
description: bill.description,
|
||||
income_expense: bill.income_expense,
|
||||
amount: String(bill.amount),
|
||||
payment_method: bill.pay_method,
|
||||
status: bill.status,
|
||||
remark: bill.remark,
|
||||
needs_review: bill.review_level,
|
||||
}));
|
||||
}
|
||||
|
||||
// 派生分析数据
|
||||
let analysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(records));
|
||||
let allAnalysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(allRecords)); // 全部数据用于每日趋势图
|
||||
// 派生分析数据(统一成 UIBill)
|
||||
let analysisRecords: UIBill[] = $derived.by(() => (isDemo ? demoRecords : records.map(cleanedBillToUIBill)));
|
||||
let allAnalysisRecords: UIBill[] = $derived.by(() => (isDemo ? demoRecords : allRecords.map(cleanedBillToUIBill)));
|
||||
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
|
||||
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user