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>
|
||||
@@ -1,29 +1,24 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
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 X from '@lucide/svelte/icons/x';
|
||||
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import { getPercentage } from '$lib/services/analysis';
|
||||
import { barColors } from '$lib/constants/chart';
|
||||
import BillRecordsTable from './BillRecordsTable.svelte';
|
||||
|
||||
interface Props {
|
||||
categoryStats: CategoryStat[];
|
||||
pieChartData: PieChartDataItem[];
|
||||
totalExpense: number;
|
||||
records: BillRecord[];
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
let { categoryStats, pieChartData, totalExpense, records }: Props = $props();
|
||||
let { categoryStats, pieChartData, totalExpense, records = $bindable(), categories = [] }: Props = $props();
|
||||
|
||||
let mode = $state<'bar' | 'pie'>('bar');
|
||||
let dialogOpen = $state(false);
|
||||
@@ -58,50 +53,14 @@
|
||||
let filteredTotalExpense = $derived(
|
||||
filteredPieChartData.reduce((sum, item) => sum + item.value, 0)
|
||||
);
|
||||
|
||||
// 排序状态
|
||||
type SortField = 'time' | 'merchant' | 'description' | 'amount';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
let sortField = $state<SortField>('time');
|
||||
let sortOrder = $state<SortOrder>('desc');
|
||||
|
||||
// 分页状态
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 10; // 每页条目数
|
||||
|
||||
let expenseStats = $derived(categoryStats.filter(s => s.expense > 0));
|
||||
|
||||
// 获取选中分类的账单记录(已排序)
|
||||
// 获取选中分类的账单记录
|
||||
let selectedRecords = $derived.by(() => {
|
||||
if (!selectedCategory) return [];
|
||||
|
||||
const filtered = records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
|
||||
|
||||
return filtered.toSorted((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case 'time':
|
||||
cmp = new Date(a.time).getTime() - new Date(b.time).getTime();
|
||||
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;
|
||||
});
|
||||
return records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
|
||||
});
|
||||
|
||||
// 分页计算
|
||||
let totalPages = $derived(Math.ceil(selectedRecords.length / pageSize));
|
||||
let paginatedRecords = $derived(
|
||||
selectedRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
);
|
||||
|
||||
// 选中分类的统计
|
||||
let selectedStat = $derived(
|
||||
@@ -110,30 +69,11 @@
|
||||
|
||||
function openCategoryDetail(category: string) {
|
||||
selectedCategory = category;
|
||||
sortField = 'time';
|
||||
sortOrder = 'desc';
|
||||
currentPage = 1;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
|
||||
<Card.Header class="flex flex-row items-center justify-between pb-2">
|
||||
<div class="space-y-1.5">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
@@ -167,7 +107,7 @@
|
||||
<div class="space-y-1">
|
||||
{#each expenseStats as stat, i}
|
||||
<button
|
||||
class="w-full text-left space-y-1 p-2 rounded-lg hover:bg-accent/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 cursor-pointer outline-none focus:outline-none"
|
||||
class="w-full text-left space-y-1 p-2 rounded-lg bg-muted/30 hover:bg-muted hover:shadow-sm hover:scale-[1.02] transition-all duration-150 cursor-pointer outline-none focus:outline-none"
|
||||
onclick={() => openCategoryDetail(stat.category)}
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
@@ -272,131 +212,7 @@
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto mt-4">
|
||||
{#if selectedRecords.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>
|
||||
<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>
|
||||
<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>
|
||||
<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}
|
||||
<Table.Row>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{record.time.substring(0, 16)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{record.merchant}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground truncate max-w-[200px]">
|
||||
{record.description || '-'}
|
||||
</Table.Cell>
|
||||
<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, selectedRecords.length)} 条,共 {selectedRecords.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}
|
||||
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
|
||||
@@ -6,79 +6,22 @@
|
||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
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 { Button } from '$lib/components/ui/button';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
import BillRecordsTable from './BillRecordsTable.svelte';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
let { records }: Props = $props();
|
||||
let { records = $bindable(), categories = [] }: Props = $props();
|
||||
|
||||
// Dialog 状态
|
||||
let dialogOpen = $state(false);
|
||||
let selectedDate = $state<Date | null>(null);
|
||||
let selectedDateRecords = $state<BillRecord[]>([]);
|
||||
|
||||
// 排序状态
|
||||
type SortField = 'time' | 'category' | 'merchant' | 'amount';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
let sortField = $state<SortField>('amount');
|
||||
let sortOrder = $state<SortOrder>('desc');
|
||||
|
||||
// 分页状态
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 8; // 每页条目数
|
||||
|
||||
// 排序后的记录
|
||||
let sortedDateRecords = $derived.by(() => {
|
||||
return selectedDateRecords.toSorted((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case 'time':
|
||||
cmp = a.time.localeCompare(b.time);
|
||||
break;
|
||||
case 'category':
|
||||
cmp = a.category.localeCompare(b.category);
|
||||
break;
|
||||
case 'merchant':
|
||||
cmp = a.merchant.localeCompare(b.merchant);
|
||||
break;
|
||||
case 'amount':
|
||||
cmp = parseFloat(a.amount) - parseFloat(b.amount);
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
});
|
||||
|
||||
// 分页计算
|
||||
let totalPages = $derived(Math.ceil(sortedDateRecords.length / pageSize));
|
||||
let paginatedRecords = $derived(
|
||||
sortedDateRecords.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;
|
||||
}
|
||||
}
|
||||
|
||||
// 时间范围选项
|
||||
type TimeRange = '7d' | '30d' | '3m';
|
||||
@@ -265,22 +208,91 @@
|
||||
return padding.top + innerHeight - (value / maxValue) * innerHeight;
|
||||
}
|
||||
|
||||
// 生成面积路径
|
||||
// 生成平滑曲线的控制点 (Catmull-Rom to Bezier)
|
||||
function getCurveControlPoints(
|
||||
p0: { x: number; y: number },
|
||||
p1: { x: number; y: number },
|
||||
p2: { x: number; y: number },
|
||||
p3: { x: number; y: number },
|
||||
tension: number = 0.3
|
||||
): { cp1: { x: number; y: number }; cp2: { x: number; y: number } } {
|
||||
const d1 = Math.sqrt(Math.pow(p1.x - p0.x, 2) + Math.pow(p1.y - p0.y, 2));
|
||||
const d2 = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
|
||||
const d3 = Math.sqrt(Math.pow(p3.x - p2.x, 2) + Math.pow(p3.y - p2.y, 2));
|
||||
|
||||
const d1a = Math.pow(d1, tension);
|
||||
const d2a = Math.pow(d2, tension);
|
||||
const d3a = Math.pow(d3, tension);
|
||||
|
||||
const cp1 = {
|
||||
x: p1.x + (d1a !== 0 ? (p2.x - p0.x) * d2a / (d1a + d2a) / 6 * tension * 6 : 0),
|
||||
y: p1.y + (d1a !== 0 ? (p2.y - p0.y) * d2a / (d1a + d2a) / 6 * tension * 6 : 0)
|
||||
};
|
||||
|
||||
const cp2 = {
|
||||
x: p2.x - (d3a !== 0 ? (p3.x - p1.x) * d2a / (d2a + d3a) / 6 * tension * 6 : 0),
|
||||
y: p2.y - (d3a !== 0 ? (p3.y - p1.y) * d2a / (d2a + d3a) / 6 * tension * 6 : 0)
|
||||
};
|
||||
|
||||
return { cp1, cp2 };
|
||||
}
|
||||
|
||||
// 生成平滑曲线路径
|
||||
function generateSmoothPath(points: { x: number; y: number }[]): string {
|
||||
if (points.length < 2) return '';
|
||||
if (points.length === 2) {
|
||||
return `L ${points[1].x},${points[1].y}`;
|
||||
}
|
||||
|
||||
let path = '';
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[Math.max(0, i - 1)];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const p3 = points[Math.min(points.length - 1, i + 2)];
|
||||
|
||||
const { cp1, cp2 } = getCurveControlPoints(p0, p1, p2, p3);
|
||||
|
||||
path += ` C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${p2.x},${p2.y}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
// 生成面积路径(平滑曲线版本)
|
||||
function generateAreaPath(category: string, data: any[], maxValue: number): string {
|
||||
if (data.length === 0) return '';
|
||||
|
||||
const points: string[] = [];
|
||||
const bottomPoints: string[] = [];
|
||||
const topPoints: { x: number; y: number }[] = [];
|
||||
const bottomPoints: { x: number; y: number }[] = [];
|
||||
|
||||
data.forEach((d, i) => {
|
||||
data.forEach((d) => {
|
||||
const x = xScale(d.date, data);
|
||||
const y1 = yScale(d[`${category}_y1`] || 0, maxValue);
|
||||
const y0 = yScale(d[`${category}_y0`] || 0, maxValue);
|
||||
points.push(`${x},${y1}`);
|
||||
bottomPoints.unshift(`${x},${y0}`);
|
||||
topPoints.push({ x, y: y1 });
|
||||
bottomPoints.unshift({ x, y: y0 });
|
||||
});
|
||||
|
||||
return `M ${points.join(' L ')} L ${bottomPoints.join(' L ')} Z`;
|
||||
// 起始点
|
||||
const startPoint = topPoints[0];
|
||||
let path = `M ${startPoint.x},${startPoint.y}`;
|
||||
|
||||
// 顶部曲线
|
||||
path += generateSmoothPath(topPoints);
|
||||
|
||||
// 连接到底部起点
|
||||
const bottomStart = bottomPoints[0];
|
||||
path += ` L ${bottomStart.x},${bottomStart.y}`;
|
||||
|
||||
// 底部曲线
|
||||
path += generateSmoothPath(bottomPoints);
|
||||
|
||||
// 闭合路径
|
||||
path += ' Z';
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
// 生成 X 轴刻度
|
||||
@@ -381,10 +393,6 @@
|
||||
return recordDateStr === dateStr;
|
||||
});
|
||||
|
||||
// 重置排序和分页状态
|
||||
sortField = 'amount';
|
||||
sortOrder = 'desc';
|
||||
currentPage = 1;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
@@ -418,7 +426,7 @@
|
||||
|
||||
{#if processedData().data.length > 1}
|
||||
{@const { data, categories, maxValue } = processedData()}
|
||||
<Card.Root>
|
||||
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
|
||||
<Card.Header class="flex flex-row items-center justify-between pb-2">
|
||||
<div class="space-y-1.5">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
@@ -669,116 +677,14 @@
|
||||
|
||||
<!-- 详细记录 -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-muted-foreground mb-2">详细记录(点击表头排序)</h4>
|
||||
<div class="rounded-md border overflow-auto max-h-[300px]">
|
||||
<!-- 表头 -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b text-xs font-medium text-muted-foreground sticky top-0">
|
||||
<button
|
||||
class="w-[60px] 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>
|
||||
<button
|
||||
class="w-[80px] 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>
|
||||
<button
|
||||
class="flex-1 min-w-[100px] 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>
|
||||
<button
|
||||
class="w-[100px] flex items-center justify-end gap-1 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>
|
||||
</div>
|
||||
<!-- 记录列表 -->
|
||||
{#each paginatedRecords as record}
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-muted/30">
|
||||
<div class="w-[60px] text-xs text-muted-foreground">
|
||||
{record.time.split(' ')[1] || '--:--'}
|
||||
</div>
|
||||
<div class="w-[80px] text-sm">{record.category}</div>
|
||||
<div class="flex-1 min-w-[100px] text-sm truncate" title="{record.merchant}">
|
||||
{record.merchant}
|
||||
</div>
|
||||
<div class="w-[100px] text-right font-mono text-sm font-medium text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-3 pt-3 border-t">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedDateRecords.length)} 条,共 {sortedDateRecords.length} 条
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</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-7 w-7 text-xs"
|
||||
onclick={() => goToPage(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
{:else if page === currentPage - 2 || page === currentPage + 2}
|
||||
<span class="px-1 text-muted-foreground text-xs">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<h4 class="text-sm font-medium text-muted-foreground mb-2">详细记录</h4>
|
||||
<BillRecordsTable
|
||||
bind:records={selectedDateRecords}
|
||||
showCategory={true}
|
||||
showDescription={false}
|
||||
pageSize={8}
|
||||
{categories}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-muted-foreground py-8">暂无数据</p>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
let maxValue = $derived(Math.max(...monthlyStats.map(s => Math.max(s.expense, s.income))));
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<BarChart3 class="h-5 w-5" />
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card.Root class="border-red-200 dark:border-red-900">
|
||||
<Card.Root class="border-red-200 dark:border-red-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-red-300 dark:hover:border-red-800 cursor-default">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">总支出</Card.Title>
|
||||
<TrendingDown class="h-4 w-4 text-red-500" />
|
||||
@@ -33,7 +33,7 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="border-green-200 dark:border-green-900">
|
||||
<Card.Root class="border-green-200 dark:border-green-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-green-300 dark:hover:border-green-800 cursor-default">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">总收入</Card.Title>
|
||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||
@@ -46,7 +46,7 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class={balance >= 0 ? 'border-blue-200 dark:border-blue-900' : 'border-orange-200 dark:border-orange-900'}>
|
||||
<Card.Root class="{balance >= 0 ? 'border-blue-200 dark:border-blue-900 hover:border-blue-300 dark:hover:border-blue-800' : 'border-orange-200 dark:border-orange-900 hover:border-orange-300 dark:hover:border-orange-800'} transition-all duration-200 hover:shadow-lg hover:-translate-y-1 cursor-default">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">结余</Card.Title>
|
||||
<Wallet class="h-4 w-4 {balance >= 0 ? 'text-blue-500' : 'text-orange-500'}" />
|
||||
|
||||
@@ -1,28 +1,121 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
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 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 type { BillRecord } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
categories: string[]; // 可用的分类列表
|
||||
onUpdate?: (record: BillRecord) => void;
|
||||
}
|
||||
|
||||
let { records }: Props = $props();
|
||||
let { records, categories, onUpdate }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let selectedRecord = $state<BillRecord | null>(null);
|
||||
let selectedRank = $state(0);
|
||||
let isEditing = $state(false);
|
||||
|
||||
// 编辑表单数据
|
||||
let editForm = $state({
|
||||
merchant: '',
|
||||
category: '',
|
||||
amount: '',
|
||||
description: '',
|
||||
payment_method: ''
|
||||
});
|
||||
|
||||
function openDetail(record: BillRecord, 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;
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!selectedRecord) return;
|
||||
|
||||
// 更新记录
|
||||
const updatedRecord: BillRecord = {
|
||||
...selectedRecord,
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
amount: editForm.amount,
|
||||
description: editForm.description,
|
||||
payment_method: editForm.payment_method
|
||||
};
|
||||
|
||||
// 更新本地数据
|
||||
const index = records.findIndex(r => r === selectedRecord);
|
||||
if (index !== -1) {
|
||||
records[index] = updatedRecord;
|
||||
}
|
||||
|
||||
selectedRecord = updatedRecord;
|
||||
isEditing = false;
|
||||
|
||||
// 通知父组件
|
||||
onUpdate?.(updatedRecord);
|
||||
}
|
||||
|
||||
function handleCategoryChange(value: string | undefined) {
|
||||
if (value) {
|
||||
editForm.category = value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Flame class="h-5 w-5 text-orange-500" />
|
||||
Top 10 单笔支出
|
||||
</Card.Title>
|
||||
<Card.Description>最大的单笔支出记录</Card.Description>
|
||||
<Card.Description>最大的单笔支出记录(点击查看详情)</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="space-y-3">
|
||||
{#each records as record, i}
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-sm">
|
||||
<button
|
||||
class="w-full flex items-center gap-4 p-3 rounded-lg bg-muted/50 transition-all duration-150 hover:bg-muted hover:scale-[1.02] hover:shadow-sm cursor-pointer text-left outline-none focus:outline-none"
|
||||
onclick={() => openDetail(record, i + 1)}
|
||||
>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full font-bold text-sm {
|
||||
i === 0 ? 'bg-gradient-to-br from-yellow-400 to-amber-500 text-white shadow-md' :
|
||||
i === 1 ? 'bg-gradient-to-br from-slate-300 to-slate-400 text-white shadow-md' :
|
||||
i === 2 ? 'bg-gradient-to-br from-orange-400 to-amber-600 text-white shadow-md' :
|
||||
'bg-primary/10 text-primary'
|
||||
}">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -34,10 +127,163 @@
|
||||
<div class="font-mono font-bold text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- 账单详情弹窗 -->
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-[450px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.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}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if selectedRecord}
|
||||
{#if isEditing}
|
||||
<!-- 编辑模式 -->
|
||||
<div class="py-4 space-y-4">
|
||||
<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">
|
||||
<!-- 金额 -->
|
||||
<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}
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer class="flex gap-2">
|
||||
{#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={() => dialogOpen = false}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onclick={startEdit}>
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -4,5 +4,6 @@ export { default as CategoryRanking } from './CategoryRanking.svelte';
|
||||
export { default as MonthlyTrend } from './MonthlyTrend.svelte';
|
||||
export { default as TopExpenses } from './TopExpenses.svelte';
|
||||
export { default as EmptyState } from './EmptyState.svelte';
|
||||
export { default as BillRecordsTable } from './BillRecordsTable.svelte';
|
||||
|
||||
|
||||
|
||||
53
web/src/lib/data/categories.ts
Normal file
53
web/src/lib/data/categories.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** 账单分类列表 */
|
||||
export const categories = [
|
||||
'餐饮美食',
|
||||
'交通出行',
|
||||
'日用百货',
|
||||
'充值缴费',
|
||||
'家居家装',
|
||||
'运动健身',
|
||||
'文化休闲',
|
||||
'数码电器',
|
||||
'医疗健康',
|
||||
'教育培训',
|
||||
'美容护理',
|
||||
'服饰鞋包',
|
||||
'宠物相关',
|
||||
'住房物业',
|
||||
'退款',
|
||||
'工资收入',
|
||||
'投资理财',
|
||||
'其他收入',
|
||||
'其他支出',
|
||||
] as const;
|
||||
|
||||
/** 分类类型 */
|
||||
export type Category = (typeof categories)[number];
|
||||
|
||||
/** 支出分类(用于分析页面筛选) */
|
||||
export const expenseCategories = [
|
||||
'餐饮美食',
|
||||
'交通出行',
|
||||
'日用百货',
|
||||
'充值缴费',
|
||||
'家居家装',
|
||||
'运动健身',
|
||||
'文化休闲',
|
||||
'数码电器',
|
||||
'医疗健康',
|
||||
'教育培训',
|
||||
'美容护理',
|
||||
'服饰鞋包',
|
||||
'宠物相关',
|
||||
'住房物业',
|
||||
'其他支出',
|
||||
] as const;
|
||||
|
||||
/** 收入分类 */
|
||||
export const incomeCategories = [
|
||||
'退款',
|
||||
'工资收入',
|
||||
'投资理财',
|
||||
'其他收入',
|
||||
] as const;
|
||||
|
||||
8
web/src/lib/data/index.ts
Normal file
8
web/src/lib/data/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { demoRecords } from './demo';
|
||||
export {
|
||||
categories,
|
||||
expenseCategories,
|
||||
incomeCategories,
|
||||
type Category
|
||||
} from './categories';
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
|
||||
// 演示数据
|
||||
import { demoRecords } from '$lib/data/demo';
|
||||
// 分类数据
|
||||
import { categories as allCategories } from '$lib/data/categories';
|
||||
|
||||
// 状态
|
||||
let fileName = $state('');
|
||||
@@ -44,6 +46,25 @@
|
||||
let totalStats = $derived(calculateTotalStats(records));
|
||||
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
|
||||
let topExpenses = $derived(getTopExpenses(records, 10));
|
||||
|
||||
// 分类列表按数据中出现次数排序(出现次数多的优先)
|
||||
let sortedCategories = $derived(() => {
|
||||
// 统计每个分类的记录数量
|
||||
const categoryCounts = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
categoryCounts.set(record.category, (categoryCounts.get(record.category) || 0) + 1);
|
||||
}
|
||||
|
||||
// 对分类进行排序:先按数据中的数量降序,未出现的分类按原顺序排在后面
|
||||
return [...allCategories].sort((a, b) => {
|
||||
const countA = categoryCounts.get(a) || 0;
|
||||
const countB = categoryCounts.get(b) || 0;
|
||||
// 数量大的排前面
|
||||
if (countA !== countB) return countB - countA;
|
||||
// 数量相同时保持原有顺序
|
||||
return allCategories.indexOf(a) - allCategories.indexOf(b);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
if (!fileName) return;
|
||||
@@ -122,7 +143,7 @@
|
||||
<OverviewCards {totalStats} {records} />
|
||||
|
||||
<!-- 每日支出趋势图(按分类堆叠) -->
|
||||
<DailyTrendChart {records} />
|
||||
<DailyTrendChart bind:records categories={sortedCategories()} />
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- 分类支出排行 -->
|
||||
@@ -130,7 +151,8 @@
|
||||
{categoryStats}
|
||||
{pieChartData}
|
||||
totalExpense={totalStats.expense}
|
||||
{records}
|
||||
bind:records
|
||||
categories={sortedCategories()}
|
||||
/>
|
||||
|
||||
<!-- 月度趋势 -->
|
||||
@@ -138,7 +160,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Top 10 支出 -->
|
||||
<TopExpenses records={topExpenses} />
|
||||
<TopExpenses records={topExpenses} categories={sortedCategories()} />
|
||||
{:else if !isLoading}
|
||||
<EmptyState onLoadDemo={loadDemoData} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user