diff --git a/CHANGELOG.md b/CHANGELOG.md index c23f4d7..500e208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.2.0] - 2026-01-25 + +### 新增 +- **账单删除功能** - 支持在账单详情抽屉中删除账单(软删除) + - 删除按钮带二次确认,防止误操作 + - 删除后数据标记为 `is_deleted`,不真正从数据库删除 + - 已删除的账单在所有查询中自动过滤 + - 账单列表和复核页面都支持删除操作 + +### 技术改进 +- 后端 MongoDB 查询方法添加软删除过滤 +- 新增 `DELETE /api/bills/:id` 接口 +- `BillDetailDrawer` 组件新增 `allowDelete` 和 `onDelete` props + ## [1.1.0] - 2026-01-23 ### 新增 diff --git a/server/handler/delete_bill.go b/server/handler/delete_bill.go new file mode 100644 index 0000000..b9fe0be --- /dev/null +++ b/server/handler/delete_bill.go @@ -0,0 +1,42 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "billai-server/repository" +) + +type DeleteBillResponse struct { + Result bool `json:"result"` + Message string `json:"message,omitempty"` +} + +// DeleteBill DELETE /api/bills/:id 删除清洗后的账单记录 +func DeleteBill(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, DeleteBillResponse{Result: false, Message: "缺少账单 ID"}) + return + } + + repo := repository.GetRepository() + if repo == nil { + c.JSON(http.StatusInternalServerError, DeleteBillResponse{Result: false, Message: "数据库未连接"}) + return + } + + err := repo.DeleteCleanedBillByID(id) + if err != nil { + if err == repository.ErrNotFound { + c.JSON(http.StatusNotFound, DeleteBillResponse{Result: false, Message: "账单不存在"}) + return + } + c.JSON(http.StatusInternalServerError, DeleteBillResponse{Result: false, Message: "删除失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, DeleteBillResponse{Result: true, Message: "删除成功"}) +} diff --git a/server/handler/update_bill.go b/server/handler/update_bill.go index 689e647..de16cbc 100644 --- a/server/handler/update_bill.go +++ b/server/handler/update_bill.go @@ -23,6 +23,7 @@ type UpdateBillRequest struct { PayMethod *string `json:"pay_method,omitempty"` Status *string `json:"status,omitempty"` Remark *string `json:"remark,omitempty"` + ReviewLevel *string `json:"review_level,omitempty"` } type UpdateBillResponse struct { @@ -119,6 +120,16 @@ func UpdateBill(c *gin.Context) { updates["remark"] = strings.TrimSpace(*req.Remark) } + if req.ReviewLevel != nil { + // 允许设置为空字符串(清除复核等级)或 HIGH/LOW + v := strings.TrimSpace(*req.ReviewLevel) + if v != "" && v != "HIGH" && v != "LOW" { + c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "review_level 只能是空、HIGH 或 LOW"}) + return + } + updates["review_level"] = v + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "没有可更新的字段"}) return diff --git a/server/model/bill.go b/server/model/bill.go index 28ef67d..6b86f4b 100644 --- a/server/model/bill.go +++ b/server/model/bill.go @@ -94,6 +94,7 @@ type CleanedBill struct { Status string `bson:"status" json:"status"` // 交易状态 Remark string `bson:"remark" json:"remark"` // 备注 ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空 + IsDeleted bool `bson:"is_deleted" json:"is_deleted"` // 是否已删除(软删除) CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间 UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` // 更新时间 SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名 diff --git a/server/repository/mongo/repository.go b/server/repository/mongo/repository.go index e208a74..013b6e5 100644 --- a/server/repository/mongo/repository.go +++ b/server/repository/mongo/repository.go @@ -192,6 +192,9 @@ func (r *Repository) GetCleanedBills(filter map[string]interface{}) ([]model.Cle bsonFilter[k] = v } + // 排除已删除的记录 + bsonFilter["is_deleted"] = bson.M{"$ne": true} + // 按时间倒序排列 opts := options.Find().SetSort(bson.D{{Key: "time", Value: -1}}) @@ -220,6 +223,9 @@ func (r *Repository) GetCleanedBillsPaged(filter map[string]interface{}, page, p bsonFilter[k] = v } + // 排除已删除的记录 + bsonFilter["is_deleted"] = bson.M{"$ne": true} + // 计算总数 total, err := r.cleanedCollection.CountDocuments(ctx, bsonFilter) if err != nil { @@ -260,6 +266,9 @@ func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpe bsonFilter[k] = v } + // 排除已删除的记录 + bsonFilter["is_deleted"] = bson.M{"$ne": true} + // 使用聚合管道按 income_expense 分组统计金额 pipeline := mongo.Pipeline{ {{Key: "$match", Value: bsonFilter}}, @@ -300,6 +309,7 @@ func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpe func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) { filter := map[string]interface{}{ "review_level": bson.M{"$in": []string{"HIGH", "LOW"}}, + "is_deleted": bson.M{"$ne": true}, } return r.GetCleanedBills(filter) } @@ -312,6 +322,8 @@ func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) { // 使用聚合管道按月份分组统计 // 先按月份和收支类型分组,再汇总 pipeline := mongo.Pipeline{ + // 排除已删除的记录 + {{Key: "$match", Value: bson.M{"is_deleted": bson.M{"$ne": true}}}}, // 添加月份字段 {{Key: "$addFields", Value: bson.D{ {Key: "month", Value: bson.D{ @@ -418,6 +430,34 @@ func (r *Repository) UpdateCleanedBillByID(id string, updates map[string]interfa return &updated, nil } +// DeleteCleanedBillByID 按 ID 软删除清洗后的账单(设置 is_deleted = true) +func (r *Repository) DeleteCleanedBillByID(id string) error { + if r.cleanedCollection == nil { + return fmt.Errorf("cleaned collection not initialized") + } + + oid, err := primitive.ObjectIDFromHex(id) + if err != nil { + return fmt.Errorf("invalid id: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{"_id": oid} + update := bson.M{"$set": bson.M{"is_deleted": true}} + result, err := r.cleanedCollection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("soft delete bill failed: %w", err) + } + + if result.MatchedCount == 0 { + return repository.ErrNotFound + } + + return nil +} + // GetClient 获取 MongoDB 客户端(用于兼容旧代码) func (r *Repository) GetClient() *mongo.Client { return r.client diff --git a/server/repository/repository.go b/server/repository/repository.go index 191347e..edaf4f7 100644 --- a/server/repository/repository.go +++ b/server/repository/repository.go @@ -46,6 +46,9 @@ type BillRepository interface { // UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录 UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error) + // DeleteCleanedBillByID 按 ID 删除清洗后的账单 + DeleteCleanedBillByID(id string) error + // CountRawByField 按字段统计原始数据数量 CountRawByField(fieldName, value string) (int64, error) } diff --git a/server/router/router.go b/server/router/router.go index 7cf4a1f..da9b3a3 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -62,6 +62,9 @@ func setupAPIRoutes(r *gin.Engine) { // 编辑账单 authed.POST("/bills/:id", handler.UpdateBill) + // 删除账单(软删除) + authed.DELETE("/bills/:id", handler.DeleteBill) + // 手动创建账单 authed.POST("/bills/manual", handler.CreateManualBills) diff --git a/web/package.json b/web/package.json index 16379fb..bf63407 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite dev", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index aaad449..04c007c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { return response.json(); } + +// 删除账单响应 +export interface DeleteBillResponse { + result: boolean; + message?: string; +} + +// 删除账单(软删除) +export async function deleteBill(id: string): Promise { + const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return response.json(); +} diff --git a/web/src/lib/components/analysis/BillDetailDrawer.svelte b/web/src/lib/components/analysis/BillDetailDrawer.svelte index 6a506d7..da790c1 100644 --- a/web/src/lib/components/analysis/BillDetailDrawer.svelte +++ b/web/src/lib/components/analysis/BillDetailDrawer.svelte @@ -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}
-
¥{record.amount.toFixed(2)}
-
支出金额
+
+ ¥{record.amount.toFixed(2)} +
+
{record.incomeExpense || '支出'}金额
@@ -263,10 +329,35 @@ {isSaving ? '保存中…' : '保存'} + {:else if showDeleteConfirm} +
+ 确定要删除这条账单吗? +
+ + +
{:else} + {#if allowDelete} + + {/if} +
+ {#if clearReviewLevel} + + {/if}
+ + + diff --git a/web/src/routes/review/+page.svelte b/web/src/routes/review/+page.svelte index ab80566..cdc49a0 100644 --- a/web/src/routes/review/+page.svelte +++ b/web/src/routes/review/+page.svelte @@ -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(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 + }; + } + } @@ -198,7 +258,10 @@ {#each filteredRecords as record} - + handleRowClick(record)} + > {record.time ? new Date(record.time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'} @@ -240,3 +303,17 @@ {/if}
+ + +