feat: 支持账单编辑(PATCH /api/bills/:id)

This commit is contained in:
clz
2026-01-18 20:17:19 +08:00
parent 339b8afe98
commit f5afb0c135
8 changed files with 326 additions and 37 deletions

View File

@@ -0,0 +1,145 @@
package handler
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"billai-server/model"
"billai-server/repository"
)
// UpdateBillRequest 账单更新请求(字段均为可选)
type UpdateBillRequest struct {
Time *string `json:"time,omitempty"`
Category *string `json:"category,omitempty"`
Merchant *string `json:"merchant,omitempty"`
Description *string `json:"description,omitempty"`
IncomeExpense *string `json:"income_expense,omitempty"`
Amount *float64 `json:"amount,omitempty"`
PayMethod *string `json:"pay_method,omitempty"`
Status *string `json:"status,omitempty"`
Remark *string `json:"remark,omitempty"`
}
type UpdateBillResponse struct {
Result bool `json:"result"`
Message string `json:"message,omitempty"`
Data *model.CleanedBill `json:"data,omitempty"`
}
func parseBillTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
formats := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04:05Z",
"2006-01-02",
}
for _, f := range formats {
if t, err := time.ParseInLocation(f, s, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unsupported time format")
}
// UpdateBill PATCH /api/bills/:id 更新清洗后的账单记录
func UpdateBill(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "缺少账单 ID"})
return
}
var req UpdateBillRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "参数解析失败: " + err.Error()})
return
}
updates := map[string]interface{}{}
if req.Time != nil {
t, err := parseBillTime(*req.Time)
if err != nil {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "时间格式错误"})
return
}
updates["time"] = t
}
if req.Category != nil {
v := strings.TrimSpace(*req.Category)
if v == "" {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "分类不能为空"})
return
}
updates["category"] = v
}
if req.Merchant != nil {
v := strings.TrimSpace(*req.Merchant)
if v == "" {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "商家不能为空"})
return
}
updates["merchant"] = v
}
if req.Description != nil {
updates["description"] = strings.TrimSpace(*req.Description)
}
if req.IncomeExpense != nil {
v := strings.TrimSpace(*req.IncomeExpense)
if v != "" && v != "收入" && v != "支出" {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "income_expense 只能是 收入 或 支出"})
return
}
updates["income_expense"] = v
}
if req.Amount != nil {
updates["amount"] = *req.Amount
}
if req.PayMethod != nil {
updates["pay_method"] = strings.TrimSpace(*req.PayMethod)
}
if req.Status != nil {
updates["status"] = strings.TrimSpace(*req.Status)
}
if req.Remark != nil {
updates["remark"] = strings.TrimSpace(*req.Remark)
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "没有可更新的字段"})
return
}
updates["updated_at"] = time.Now()
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusInternalServerError, UpdateBillResponse{Result: false, Message: "数据库未连接"})
return
}
updated, err := repo.UpdateCleanedBillByID(id, updates)
if err != nil {
if err == repository.ErrNotFound {
c.JSON(http.StatusNotFound, UpdateBillResponse{Result: false, Message: "账单不存在"})
return
}
c.JSON(http.StatusInternalServerError, UpdateBillResponse{Result: false, Message: "更新失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, UpdateBillResponse{Result: true, Message: "更新成功", Data: updated})
}

View File

@@ -0,0 +1,6 @@
package repository
import "errors"
// ErrNotFound 表示目标记录不存在
var ErrNotFound = errors.New("not found")

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
"go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/options"
@@ -383,6 +384,40 @@ func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
return stats, nil return stats, nil
} }
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
func (r *Repository) UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error) {
if r.cleanedCollection == nil {
return nil, fmt.Errorf("cleaned collection not initialized")
}
oid, err := primitive.ObjectIDFromHex(id)
if err != nil {
return nil, fmt.Errorf("invalid id: %w", err)
}
if len(updates) == 0 {
return nil, fmt.Errorf("no updates")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := bson.M{"_id": oid}
update := bson.M{"$set": updates}
opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
var updated model.CleanedBill
err = r.cleanedCollection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updated)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, repository.ErrNotFound
}
return nil, fmt.Errorf("update bill failed: %w", err)
}
return &updated, nil
}
// GetClient 获取 MongoDB 客户端(用于兼容旧代码) // GetClient 获取 MongoDB 客户端(用于兼容旧代码)
func (r *Repository) GetClient() *mongo.Client { func (r *Repository) GetClient() *mongo.Client {
return r.client return r.client

View File

@@ -43,6 +43,9 @@ type BillRepository interface {
// GetBillsNeedReview 获取需要复核的账单 // GetBillsNeedReview 获取需要复核的账单
GetBillsNeedReview() ([]model.CleanedBill, error) GetBillsNeedReview() ([]model.CleanedBill, error)
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error)
// CountRawByField 按字段统计原始数据数量 // CountRawByField 按字段统计原始数据数量
CountRawByField(fieldName, value string) (int64, error) CountRawByField(fieldName, value string) (int64, error)
} }

View File

@@ -59,6 +59,9 @@ func setupAPIRoutes(r *gin.Engine) {
// 账单查询 // 账单查询
authed.GET("/bills", handler.ListBills) authed.GET("/bills", handler.ListBills)
// 编辑账单
authed.PATCH("/bills/:id", handler.UpdateBill)
// 手动创建账单 // 手动创建账单
authed.POST("/bills/manual", handler.CreateManualBills) authed.POST("/bills/manual", handler.CreateManualBills)

View File

@@ -249,6 +249,42 @@ export interface CleanedBill {
review_level: string; review_level: string;
} }
// 更新账单
export interface UpdateBillRequest {
time?: string;
category?: string;
merchant?: string;
description?: string;
income_expense?: string;
amount?: number;
pay_method?: string;
status?: string;
remark?: string;
}
export interface UpdateBillResponse {
result: boolean;
message?: string;
data?: CleanedBill;
}
export async function updateBill(id: string, patch: UpdateBillRequest): Promise<UpdateBillResponse> {
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(patch),
});
if (!response.ok) {
// keep same behavior as other API calls
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// 账单列表请求参数 // 账单列表请求参数
export interface FetchBillsParams { export interface FetchBillsParams {
page?: number; page?: number;

View File

@@ -19,7 +19,7 @@
import Tag from '@lucide/svelte/icons/tag'; import Tag from '@lucide/svelte/icons/tag';
import FileText from '@lucide/svelte/icons/file-text'; import FileText from '@lucide/svelte/icons/file-text';
import CreditCard from '@lucide/svelte/icons/credit-card'; import CreditCard from '@lucide/svelte/icons/credit-card';
import type { BillRecord } from '$lib/api'; import { updateBill, type BillRecord } from '$lib/api';
interface Props { interface Props {
records: BillRecord[]; records: BillRecord[];
@@ -53,6 +53,7 @@
let selectedRecord = $state<BillRecord | null>(null); let selectedRecord = $state<BillRecord | null>(null);
let selectedIndex = $state(-1); let selectedIndex = $state(-1);
let isEditing = $state(false); let isEditing = $state(false);
let isSaving = $state(false);
let editForm = $state({ let editForm = $state({
amount: '', amount: '',
merchant: '', merchant: '',
@@ -135,9 +136,12 @@
} }
// 保存编辑 // 保存编辑
function saveEdit() { async function saveEdit() {
if (!selectedRecord) return; if (!selectedRecord) return;
if (isSaving) return;
isSaving = true;
const original = { ...selectedRecord }; const original = { ...selectedRecord };
const updated: BillRecord = { const updated: BillRecord = {
...selectedRecord, ...selectedRecord,
@@ -148,22 +152,51 @@
payment_method: editForm.payment_method payment_method: editForm.payment_method
}; };
// 更新本地数据 try {
const idx = records.findIndex(r => // 如果有后端 ID则持久化更新
r.time === selectedRecord!.time && const billId = (selectedRecord as unknown as { id?: string }).id;
r.merchant === selectedRecord!.merchant && if (billId) {
r.amount === selectedRecord!.amount const resp = await updateBill(billId, {
); merchant: editForm.merchant,
if (idx !== -1) { category: editForm.category,
records[idx] = updated; amount: Number(editForm.amount),
records = [...records]; // 触发响应式更新 description: editForm.description,
pay_method: editForm.payment_method,
});
if (resp.result && resp.data) {
// 将后端返回的 CleanedBill 映射为 BillRecord
updated.amount = String(resp.data.amount);
updated.merchant = resp.data.merchant;
updated.category = resp.data.category;
updated.description = resp.data.description || '';
updated.payment_method = resp.data.pay_method || '';
// 让时间展示更稳定:使用后端格式
updated.time = resp.data.time;
}
}
// 更新本地数据fallback按引用/关键字段查找)
const idx = records.findIndex(r => r === selectedRecord);
const finalIdx = idx !== -1
? idx
: records.findIndex(r =>
r.time === selectedRecord!.time &&
r.merchant === selectedRecord!.merchant &&
r.amount === selectedRecord!.amount
);
if (finalIdx !== -1) {
records[finalIdx] = updated;
records = [...records];
}
selectedRecord = updated;
isEditing = false;
onUpdate?.(updated, original);
} finally {
isSaving = false;
} }
selectedRecord = updated;
isEditing = false;
// 通知父组件
onUpdate?.(updated, original);
} }
// 处理分类选择 // 处理分类选择
@@ -335,7 +368,7 @@
<!-- 详情/编辑弹窗 --> <!-- 详情/编辑弹窗 -->
<Drawer.Root bind:open={detailDialogOpen}> <Drawer.Root bind:open={detailDialogOpen}>
<Drawer.Content class="sm:max-w-md"> <Drawer.Content class="md:max-w-md">
<Drawer.Header> <Drawer.Header>
<Drawer.Title class="flex items-center gap-2"> <Drawer.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" /> <Receipt class="h-5 w-5" />
@@ -465,9 +498,9 @@
<X class="h-4 w-4 mr-2" /> <X class="h-4 w-4 mr-2" />
取消 取消
</Button> </Button>
<Button onclick={saveEdit}> <Button onclick={saveEdit} disabled={isSaving}>
<Save class="h-4 w-4 mr-2" /> <Save class="h-4 w-4 mr-2" />
保存 {isSaving ? '保存中…' : '保存'}
</Button> </Button>
{:else} {:else}
<Button variant="outline" onclick={() => detailDialogOpen = false}> <Button variant="outline" onclick={() => detailDialogOpen = false}>

View File

@@ -15,7 +15,7 @@
import Tag from '@lucide/svelte/icons/tag'; import Tag from '@lucide/svelte/icons/tag';
import FileText from '@lucide/svelte/icons/file-text'; import FileText from '@lucide/svelte/icons/file-text';
import CreditCard from '@lucide/svelte/icons/credit-card'; import CreditCard from '@lucide/svelte/icons/credit-card';
import type { BillRecord } from '$lib/api'; import { updateBill, type BillRecord } from '$lib/api';
interface Props { interface Props {
records: BillRecord[]; records: BillRecord[];
@@ -29,6 +29,7 @@
let selectedRecord = $state<BillRecord | null>(null); let selectedRecord = $state<BillRecord | null>(null);
let selectedRank = $state(0); let selectedRank = $state(0);
let isEditing = $state(false); let isEditing = $state(false);
let isSaving = $state(false);
// 编辑表单数据 // 编辑表单数据
let editForm = $state({ let editForm = $state({
@@ -62,9 +63,12 @@
isEditing = false; isEditing = false;
} }
function saveEdit() { async function saveEdit() {
if (!selectedRecord) return; if (!selectedRecord) return;
if (isSaving) return;
isSaving = true;
// 更新记录 // 更新记录
const updatedRecord: BillRecord = { const updatedRecord: BillRecord = {
...selectedRecord, ...selectedRecord,
@@ -75,17 +79,41 @@
payment_method: editForm.payment_method payment_method: editForm.payment_method
}; };
// 更新本地数据 try {
const index = records.findIndex(r => r === selectedRecord); const billId = (selectedRecord as unknown as { id?: string }).id;
if (index !== -1) { if (billId) {
records[index] = updatedRecord; const resp = await updateBill(billId, {
merchant: editForm.merchant,
category: editForm.category,
amount: Number(editForm.amount),
description: editForm.description,
pay_method: editForm.payment_method,
});
if (resp.result && resp.data) {
updatedRecord.amount = String(resp.data.amount);
updatedRecord.merchant = resp.data.merchant;
updatedRecord.category = resp.data.category;
updatedRecord.description = resp.data.description || '';
updatedRecord.payment_method = resp.data.pay_method || '';
updatedRecord.time = resp.data.time;
}
}
// 更新本地数据
const index = records.findIndex(r => r === selectedRecord);
if (index !== -1) {
records[index] = updatedRecord;
}
selectedRecord = updatedRecord;
isEditing = false;
// 通知父组件
onUpdate?.(updatedRecord);
} finally {
isSaving = false;
} }
selectedRecord = updatedRecord;
isEditing = false;
// 通知父组件
onUpdate?.(updatedRecord);
} }
function handleCategoryChange(value: string | undefined) { function handleCategoryChange(value: string | undefined) {
@@ -135,7 +163,7 @@
<!-- 账单详情弹窗 --> <!-- 账单详情弹窗 -->
<Drawer.Root bind:open={dialogOpen}> <Drawer.Root bind:open={dialogOpen}>
<Drawer.Content class="sm:max-w-[450px]"> <Drawer.Content class="md:max-w-[450px]">
<Drawer.Header> <Drawer.Header>
<Drawer.Title class="flex items-center gap-2"> <Drawer.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" /> <Receipt class="h-5 w-5" />
@@ -271,9 +299,9 @@
<X class="h-4 w-4 mr-2" /> <X class="h-4 w-4 mr-2" />
取消 取消
</Button> </Button>
<Button onclick={saveEdit}> <Button onclick={saveEdit} disabled={isSaving}>
<Save class="h-4 w-4 mr-2" /> <Save class="h-4 w-4 mr-2" />
保存 {isSaving ? '保存中…' : '保存'}
</Button> </Button>
{:else} {:else}
<Button variant="outline" onclick={() => dialogOpen = false}> <Button variant="outline" onclick={() => dialogOpen = false}>