feat: 添加账单软删除功能

- 新增删除按钮(带二次确认)到账单详情抽屉
- 后端实现软删除(设置 is_deleted 标记)
- 所有查询方法自动过滤已删除记录
- 账单列表和复核页面都支持删除
- 版本更新至 1.2.0
This commit is contained in:
clz
2026-01-25 18:49:07 +08:00
parent a97a8d6a20
commit bacbabc0a5
12 changed files with 373 additions and 8 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",

View File

@@ -251,6 +251,7 @@ export interface UpdateBillRequest {
pay_method?: string;
status?: string;
remark?: string;
review_level?: string;
}
export interface UpdateBillResponse {
@@ -393,3 +394,22 @@ export async function fetchBillsByReviewLevel(): Promise<BillsResponse> {
return response.json();
}
// 删除账单响应
export interface DeleteBillResponse {
result: boolean;
message?: string;
}
// 删除账单(软删除)
export async function deleteBill(id: string): Promise<DeleteBillResponse> {
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}

View File

@@ -11,13 +11,15 @@
import Pencil from '@lucide/svelte/icons/pencil';
import Save from '@lucide/svelte/icons/save';
import X from '@lucide/svelte/icons/x';
import Check from '@lucide/svelte/icons/check';
import Trash2 from '@lucide/svelte/icons/trash-2';
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 { updateBill, deleteBill } from '$lib/api';
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
interface Props {
@@ -33,7 +35,14 @@
contentClass?: string;
/** 保存时是否清除 review_level用于复核场景 */
clearReviewLevel?: boolean;
/** 是否允许删除 */
allowDelete?: boolean;
onUpdate?: (updated: UIBill, original: UIBill) => void;
onDelete?: (deleted: UIBill) => void;
}
let {
@@ -45,11 +54,17 @@
editDescription = '修改这笔支出的信息',
titleExtra,
contentClass,
onUpdate
clearReviewLevel = false,
allowDelete = false,
onUpdate,
onDelete
}: Props = $props();
let isEditing = $state(false);
let isSaving = $state(false);
let isConfirming = $state(false);
let isDeleting = $state(false);
let showDeleteConfirm = $state(false);
let editForm = $state({
amount: '',
@@ -62,6 +77,7 @@
$effect(() => {
if (!open) return;
isEditing = false;
showDeleteConfirm = false;
});
function startEdit() {
@@ -84,6 +100,52 @@
if (value) editForm.category = value;
}
// 确认正确(仅清除 review_level不修改其他字段
async function confirmCorrect() {
if (!record || isConfirming) return;
isConfirming = true;
const original = { ...record };
try {
const billId = record.id;
if (billId) {
const resp = await updateBill(billId, { review_level: '' });
if (resp.result && resp.data) {
const updated = cleanedBillToUIBill(resp.data);
record = updated;
onUpdate?.(updated, original);
}
}
} finally {
isConfirming = false;
}
}
// 删除账单
async function handleDelete() {
if (!record || isDeleting) return;
isDeleting = true;
const deleted = { ...record };
try {
const billId = record.id;
if (billId) {
const resp = await deleteBill(billId);
if (resp.result) {
open = false;
onDelete?.(deleted);
}
}
} finally {
isDeleting = false;
showDeleteConfirm = false;
}
}
async function saveEdit() {
if (!record) return;
if (isSaving) return;
@@ -108,7 +170,9 @@
category: editForm.category,
amount: Number(editForm.amount),
description: editForm.description,
pay_method: editForm.payment_method
pay_method: editForm.payment_method,
// 复核模式下清除 review_level
...(clearReviewLevel ? { review_level: '' } : {})
});
if (resp.result && resp.data) {
@@ -199,8 +263,10 @@
{: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 class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
¥{record.amount.toFixed(2)}
</div>
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>
</div>
<div class="space-y-3">
@@ -263,10 +329,35 @@
<Save class="h-4 w-4 mr-2" />
{isSaving ? '保存中…' : '保存'}
</Button>
{:else if showDeleteConfirm}
<div class="flex items-center gap-2 w-full">
<span class="text-sm text-muted-foreground">确定要删除这条账单吗?</span>
<div class="flex-1"></div>
<Button variant="outline" onclick={() => (showDeleteConfirm = false)} disabled={isDeleting}>
取消
</Button>
<Button variant="destructive" onclick={handleDelete} disabled={isDeleting}>
<Trash2 class="h-4 w-4 mr-2" />
{isDeleting ? '删除中…' : '确认删除'}
</Button>
</div>
{:else}
{#if allowDelete}
<Button variant="outline" class="text-red-600 hover:text-red-700 hover:bg-red-50" onclick={() => (showDeleteConfirm = true)}>
<Trash2 class="h-4 w-4 mr-2" />
删除
</Button>
{/if}
<div class="flex-1"></div>
<Button variant="outline" onclick={() => (open = false)}>
关闭
</Button>
{#if clearReviewLevel}
<Button class="bg-green-600 hover:bg-green-700 text-white" onclick={confirmCorrect} disabled={isConfirming}>
<Check class="h-4 w-4 mr-2" />
{isConfirming ? '确认中…' : '确认正确'}
</Button>
{/if}
<Button onclick={startEdit}>
<Pencil class="h-4 w-4 mr-2" />
编辑

View File

@@ -12,6 +12,8 @@
import { Separator } from '$lib/components/ui/separator';
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
import { formatLocalDate, formatDateTime } from '$lib/utils';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
@@ -178,6 +180,51 @@
// 重新加载账单列表(保持当前日期筛选)
loadBills();
}
// 账单详情抽屉
let drawerOpen = $state(false);
let selectedBill = $state<UIBill | null>(null);
// 点击行打开详情
function handleRowClick(record: CleanedBill) {
selectedBill = cleanedBillToUIBill(record);
drawerOpen = true;
}
// 更新账单后刷新列表
function handleBillUpdate(updated: UIBill) {
// 在当前列表中更新对应的记录
const index = records.findIndex(r => r.id === updated.id);
if (index !== -1) {
records[index] = {
...records[index],
time: updated.time,
category: updated.category,
merchant: updated.merchant,
description: updated.description || '',
income_expense: updated.incomeExpense,
amount: updated.amount,
pay_method: updated.paymentMethod || '',
status: updated.status || '',
remark: updated.remark || '',
review_level: updated.reviewLevel || '',
};
}
}
// 删除账单后刷新列表
function handleBillDelete(deleted: UIBill) {
// 从列表中移除对应的记录
records = records.filter(r => r.id !== deleted.id);
totalRecords = Math.max(0, totalRecords - 1);
// 更新聚合统计
if (deleted.incomeExpense === '支出') {
totalExpense = Math.max(0, totalExpense - deleted.amount);
} else if (deleted.incomeExpense === '收入') {
totalIncome = Math.max(0, totalIncome - deleted.amount);
}
}
</script>
<svelte:head>
@@ -383,7 +430,10 @@
</Table.Header>
<Table.Body>
{#each displayRecords as record}
<Table.Row>
<Table.Row
class="cursor-pointer hover:bg-muted/50 transition-colors"
onclick={() => handleRowClick(record)}
>
<Table.Cell class="text-muted-foreground text-sm">
{formatDateTime(record.time)}
</Table.Cell>
@@ -486,3 +536,16 @@
<ManualBillInput onSuccess={handleManualBillSuccess} />
{/if}
</div>
<!-- 账单详情抽屉 -->
<BillDetailDrawer
bind:open={drawerOpen}
bind:record={selectedBill}
categories={categories}
title="账单详情"
viewDescription="查看这笔账单的完整信息"
editDescription="修改这笔账单的信息"
allowDelete={true}
onUpdate={handleBillUpdate}
onDelete={handleBillDelete}
/>

View File

@@ -5,6 +5,8 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Table from '$lib/components/ui/table';
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
@@ -68,6 +70,64 @@
let totalCount = $derived(reviewStats?.total || 0);
let highCount = $derived(reviewStats?.high || 0);
let lowCount = $derived(reviewStats?.low || 0);
// 账单详情抽屉
let drawerOpen = $state(false);
let selectedBill = $state<UIBill | null>(null);
// 分类列表(用于编辑选择)
const categories = [
'餐饮美食', '交通出行', '生活服务', '日用百货',
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
];
// 点击行打开详情
function handleRowClick(record: CleanedBill) {
selectedBill = cleanedBillToUIBill(record);
drawerOpen = true;
}
// 复核完成后从列表中移除该记录
function handleBillUpdate(updated: UIBill, original: UIBill) {
// 更新后 review_level 已被清除,从列表中移除
const index = allBills.findIndex(r => r.id === updated.id);
if (index !== -1) {
allBills.splice(index, 1);
// 触发响应式更新
allBills = [...allBills];
}
// 更新统计数据(根据原始的 review_level 减少计数)
if (reviewStats) {
reviewStats = {
...reviewStats,
total: Math.max(0, reviewStats.total - 1),
high: original.reviewLevel === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
low: original.reviewLevel === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
};
}
// 关闭抽屉
drawerOpen = false;
}
// 删除账单后从列表中移除该记录
function handleBillDelete(deleted: UIBill) {
// 从列表中移除对应的记录
const index = allBills.findIndex(r => r.id === deleted.id);
if (index !== -1) {
allBills.splice(index, 1);
allBills = [...allBills];
}
// 更新统计数据
if (reviewStats) {
reviewStats = {
...reviewStats,
total: Math.max(0, reviewStats.total - 1),
high: deleted.reviewLevel === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
low: deleted.reviewLevel === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
};
}
}
</script>
<svelte:head>
@@ -198,7 +258,10 @@
</Table.Header>
<Table.Body>
{#each filteredRecords as record}
<Table.Row>
<Table.Row
class="cursor-pointer hover:bg-muted/50 transition-colors"
onclick={() => handleRowClick(record)}
>
<Table.Cell class="text-muted-foreground text-sm">
{record.time ? new Date(record.time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}
</Table.Cell>
@@ -240,3 +303,17 @@
</Card.Root>
{/if}
</div>
<!-- 账单详情抽屉(复核模式) -->
<BillDetailDrawer
bind:open={drawerOpen}
bind:record={selectedBill}
categories={categories}
title="复核账单"
viewDescription="确认或修改这笔账单的分类"
editDescription="修改这笔账单的分类信息"
clearReviewLevel={true}
allowDelete={true}
onUpdate={handleBillUpdate}
onDelete={handleBillDelete}
/>