feat(analysis): 添加账单详情查看和编辑功能
- BillRecordsTable: 新增点击行查看详情弹窗,支持编辑模式 - CategoryRanking: 分类支出表格支持点击查看/编辑账单详情 - DailyTrendChart: 每日趋势表格支持点击查看/编辑账单详情 - TopExpenses: Top10支出支持点击查看/编辑,前三名高亮显示 - OverviewCards/MonthlyTrend: 添加卡片hover效果 - 新增 categories.ts: 集中管理账单分类数据 - 分类下拉按使用频率排序
This commit is contained in:
483
web/src/lib/components/analysis/BillRecordsTable.svelte
Normal file
483
web/src/lib/components/analysis/BillRecordsTable.svelte
Normal file
@@ -0,0 +1,483 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
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 type { BillRecord } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
showCategory?: boolean;
|
||||
showDescription?: boolean;
|
||||
pageSize?: number;
|
||||
categories?: string[];
|
||||
onUpdate?: (updated: BillRecord, original: BillRecord) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
records = $bindable(),
|
||||
showCategory = false,
|
||||
showDescription = true,
|
||||
pageSize = 10,
|
||||
categories = [],
|
||||
onUpdate
|
||||
}: Props = $props();
|
||||
|
||||
// 排序状态
|
||||
type SortField = 'time' | 'category' | 'merchant' | 'description' | 'amount';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
let sortField = $state<SortField>('time');
|
||||
let sortOrder = $state<SortOrder>('desc');
|
||||
|
||||
// 分页状态
|
||||
let currentPage = $state(1);
|
||||
|
||||
// 详情弹窗状态
|
||||
let detailDialogOpen = $state(false);
|
||||
let selectedRecord = $state<BillRecord | null>(null);
|
||||
let selectedIndex = $state(-1);
|
||||
let isEditing = $state(false);
|
||||
let editForm = $state({
|
||||
amount: '',
|
||||
merchant: '',
|
||||
category: '',
|
||||
description: '',
|
||||
payment_method: ''
|
||||
});
|
||||
|
||||
// 排序后的记录
|
||||
let sortedRecords = $derived.by(() => {
|
||||
return records.toSorted((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case 'time':
|
||||
cmp = new Date(a.time).getTime() - new Date(b.time).getTime();
|
||||
break;
|
||||
case 'category':
|
||||
cmp = a.category.localeCompare(b.category);
|
||||
break;
|
||||
case 'merchant':
|
||||
cmp = a.merchant.localeCompare(b.merchant);
|
||||
break;
|
||||
case 'description':
|
||||
cmp = (a.description || '').localeCompare(b.description || '');
|
||||
break;
|
||||
case 'amount':
|
||||
cmp = parseFloat(a.amount) - parseFloat(b.amount);
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
});
|
||||
|
||||
// 分页计算
|
||||
let totalPages = $derived(Math.ceil(sortedRecords.length / pageSize));
|
||||
let paginatedRecords = $derived(
|
||||
sortedRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
);
|
||||
|
||||
function toggleSort(field: SortField) {
|
||||
if (sortField === field) {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = field;
|
||||
sortOrder = field === 'amount' ? 'desc' : 'asc';
|
||||
}
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
}
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
function openDetail(record: BillRecord, index: number) {
|
||||
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 cancelEdit() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
function saveEdit() {
|
||||
if (!selectedRecord) return;
|
||||
|
||||
const original = { ...selectedRecord };
|
||||
const updated: BillRecord = {
|
||||
...selectedRecord,
|
||||
amount: editForm.amount,
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
description: editForm.description,
|
||||
payment_method: editForm.payment_method
|
||||
};
|
||||
|
||||
// 更新本地数据
|
||||
const idx = records.findIndex(r =>
|
||||
r.time === selectedRecord!.time &&
|
||||
r.merchant === selectedRecord!.merchant &&
|
||||
r.amount === selectedRecord!.amount
|
||||
);
|
||||
if (idx !== -1) {
|
||||
records[idx] = updated;
|
||||
records = [...records]; // 触发响应式更新
|
||||
}
|
||||
|
||||
selectedRecord = updated;
|
||||
isEditing = false;
|
||||
|
||||
// 通知父组件
|
||||
onUpdate?.(updated, original);
|
||||
}
|
||||
|
||||
// 处理分类选择
|
||||
function handleCategoryChange(value: string | undefined) {
|
||||
if (value) {
|
||||
editForm.category = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置分页(当记录变化时)
|
||||
$effect(() => {
|
||||
records;
|
||||
currentPage = 1;
|
||||
sortField = 'time';
|
||||
sortOrder = 'desc';
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if records.length > 0}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[140px]">
|
||||
<button
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('time')}
|
||||
>
|
||||
时间
|
||||
{#if sortField === 'time'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{#if showCategory}
|
||||
<Table.Head class="w-[100px]">
|
||||
<button
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('category')}
|
||||
>
|
||||
分类
|
||||
{#if sortField === 'category'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/if}
|
||||
<Table.Head>
|
||||
<button
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('merchant')}
|
||||
>
|
||||
商家
|
||||
{#if sortField === 'merchant'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{#if showDescription}
|
||||
<Table.Head>
|
||||
<button
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('description')}
|
||||
>
|
||||
描述
|
||||
{#if sortField === 'description'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/if}
|
||||
<Table.Head class="text-right w-[100px]">
|
||||
<button
|
||||
class="flex items-center gap-1 ml-auto hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('amount')}
|
||||
>
|
||||
金额
|
||||
{#if sortField === 'amount'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedRecords as record, i}
|
||||
<Table.Row
|
||||
class="hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onclick={() => openDetail(record, (currentPage - 1) * pageSize + i)}
|
||||
>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{record.time.substring(0, 16)}
|
||||
</Table.Cell>
|
||||
{#if showCategory}
|
||||
<Table.Cell class="text-sm">{record.category}</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="font-medium">{record.merchant}</Table.Cell>
|
||||
{#if showDescription}
|
||||
<Table.Cell class="text-muted-foreground truncate max-w-[200px]">
|
||||
{record.description || '-'}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRecords.length)} 条,共 {sortedRecords.length} 条
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
||||
<Button
|
||||
variant={page === currentPage ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => goToPage(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
{:else if page === currentPage - 2 || page === currentPage + 2}
|
||||
<span class="px-1 text-muted-foreground">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
暂无记录
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 详情/编辑弹窗 -->
|
||||
<Dialog.Root bind:open={detailDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : '账单详情'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if selectedRecord}
|
||||
{#if isEditing}
|
||||
<!-- 编辑表单 -->
|
||||
<div class="space-y-4 py-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 class="py-4">
|
||||
<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}
|
||||
|
||||
<Dialog.Footer>
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit}>
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={() => detailDialogOpen = false}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onclick={startEdit}>
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user