refactor(web): unify bills as UIBill, remove BillRecord

This commit is contained in:
clz
2026-01-18 21:14:54 +08:00
parent c61691249f
commit 65ea2fa477
13 changed files with 484 additions and 613 deletions

View 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>

View File

@@ -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}
/>

View File

@@ -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 === '支出');
});
// 选中分类的统计

View File

@@ -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

View File

@@ -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();

View File

@@ -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>

View File

@@ -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() {