From 06f6c847d870f9b51feaa15dfd829bee92ae20a9 Mon Sep 17 00:00:00 2001 From: clz Date: Sat, 10 Jan 2026 20:48:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=8B=E5=8A=A8=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8A=9F=E8=83=BD=E5=8F=8A=E6=97=B6=E5=8C=BA?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增手动账单输入功能 - 创建 ManualBillInput 组件,支持批量添加账单 - 添加服务器端 API /api/bills/manual 处理手动账单创建 - 支持时间选择器,默认当前时间 - 交易对方字段设为可选 - 实时显示待提交账单列表 - 提交成功后显示成功/失败/重复统计 - 修复时区问题 - 后端使用 time.ParseInLocation 解析本地时间,避免 UTC 时区错误 - 确保手动输入的时间按本地时区正确存储 - UI 优化 - 账单管理页面添加标签页切换(列表/手动添加) - 主页添加快捷按钮跳转至手动添加页面 - 手动账单来源正确显示为"手动输入" - 使用 shadcn-svelte 组件统一 UI 风格 - 提交成功后保持日期筛选并重新加载数据 --- server/handler/manual_bills.go | 133 +++++ server/router/router.go | 3 + web/src/lib/api.ts | 43 ++ .../components/bills/ManualBillInput.svelte | 459 ++++++++++++++++++ web/src/routes/+page.svelte | 14 +- web/src/routes/bills/+page.svelte | 173 +++++-- 6 files changed, 778 insertions(+), 47 deletions(-) create mode 100644 server/handler/manual_bills.go create mode 100644 web/src/lib/components/bills/ManualBillInput.svelte diff --git a/server/handler/manual_bills.go b/server/handler/manual_bills.go new file mode 100644 index 0000000..9db4e79 --- /dev/null +++ b/server/handler/manual_bills.go @@ -0,0 +1,133 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "billai-server/model" + "billai-server/repository" +) + +// ManualBillInput 手动输入的账单 +type ManualBillInput struct { + Time string `json:"time" binding:"required"` // 交易时间 + Category string `json:"category" binding:"required"` // 交易分类 + Merchant string `json:"merchant" binding:"required"` // 交易对方 + Description string `json:"description"` // 商品说明 + IncomeExpense string `json:"income_expense" binding:"required"` // 收/支 + Amount float64 `json:"amount" binding:"required"` // 金额 + PayMethod string `json:"pay_method"` // 支付方式 + Status string `json:"status"` // 交易状态 + Remark string `json:"remark"` // 备注 +} + +// CreateManualBillsRequest 批量创建手动账单请求 +type CreateManualBillsRequest struct { + Bills []ManualBillInput `json:"bills" binding:"required,min=1"` +} + +// CreateManualBillsResponse 批量创建手动账单响应 +type CreateManualBillsResponse struct { + Result bool `json:"result"` + Message string `json:"message,omitempty"` + Data *CreateManualBillsData `json:"data,omitempty"` +} + +// CreateManualBillsData 批量创建手动账单数据 +type CreateManualBillsData struct { + Success int `json:"success"` // 成功创建数量 + Failed int `json:"failed"` // 失败数量 + Duplicates int `json:"duplicates"` // 重复数量 +} + +// CreateManualBills 批量创建手动输入的账单 +func CreateManualBills(c *gin.Context) { + var req CreateManualBillsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, CreateManualBillsResponse{ + Result: false, + Message: "参数解析失败: " + err.Error(), + }) + return + } + + // 获取 repository + repo := repository.GetRepository() + if repo == nil { + c.JSON(http.StatusInternalServerError, CreateManualBillsResponse{ + Result: false, + Message: "数据库未连接", + }) + return + } + + // 转换为 CleanedBill + bills := make([]model.CleanedBill, 0, len(req.Bills)) + for _, input := range req.Bills { + // 解析时间(使用本地时区,避免 UTC 时区问题) + t, err := time.ParseInLocation("2006-01-02 15:04:05", input.Time, time.Local) + if err != nil { + // 尝试另一种格式 + t, err = time.ParseInLocation("2006-01-02T15:04:05Z07:00", input.Time, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, CreateManualBillsResponse{ + Result: false, + Message: "时间格式错误: " + input.Time, + }) + return + } + } + + // 设置默认值 + status := input.Status + if status == "" { + status = "交易成功" + } + + bill := model.CleanedBill{ + BillType: "manual", + TransactionID: "", // 手动账单不设置交易ID + MerchantOrderNo: "", + Time: t, + Category: input.Category, + Merchant: input.Merchant, + Description: input.Description, + IncomeExpense: input.IncomeExpense, + Amount: input.Amount, + PayMethod: input.PayMethod, + Status: status, + Remark: input.Remark, + ReviewLevel: "", // 手动账单不需要复核 + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SourceFile: "manual_input", + UploadBatch: time.Now().Format("20060102_150405"), + } + + bills = append(bills, bill) + } + + // 保存账单(带去重) + saved, duplicates, err := repo.SaveCleanedBills(bills) + if err != nil { + c.JSON(http.StatusInternalServerError, CreateManualBillsResponse{ + Result: false, + Message: "保存失败: " + err.Error(), + }) + return + } + + failed := len(bills) - saved - duplicates + + c.JSON(http.StatusOK, CreateManualBillsResponse{ + Result: true, + Message: "创建成功", + Data: &CreateManualBillsData{ + Success: saved, + Failed: failed, + Duplicates: duplicates, + }, + }) +} diff --git a/server/router/router.go b/server/router/router.go index 3229f6b..bd1b045 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -50,6 +50,9 @@ func setupAPIRoutes(r *gin.Engine) { // 账单查询 api.GET("/bills", handler.ListBills) + // 手动创建账单 + api.POST("/bills/manual", handler.CreateManualBills) + // 月度统计(全部数据) api.GET("/monthly-stats", handler.MonthlyStats) } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0865eef..8cfe2f7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -273,5 +273,48 @@ export async function fetchBills(params: FetchBillsParams = {}): Promise { + const response = await fetch(`${API_BASE}/api/bills/manual`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ bills }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return response.json(); +} diff --git a/web/src/lib/components/bills/ManualBillInput.svelte b/web/src/lib/components/bills/ManualBillInput.svelte new file mode 100644 index 0000000..e0a2b8f --- /dev/null +++ b/web/src/lib/components/bills/ManualBillInput.svelte @@ -0,0 +1,459 @@ + + +
+ + + + + + 手动添加账单 + + 填写账单信息,可以连续添加多条 + + +
+ +
+ +
+ + +
+

已选择:{formData.time || '未选择'}

+
+ + +
+ + + + {formData.category || '请选择分类'} + + + + {#each categories as cat} + {cat} + {/each} + + + +
+ + +
+ + + + {formData.income_expense} + + + + 支出 + 收入 + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ + + +
+ + + + +
+
+ 待提交列表 + + {pendingBills.length} 条账单 + +
+ {#if pendingBills.length > 0} + + {/if} +
+
+ + {#if pendingBills.length === 0} +
+ +

暂无账单

+
+ {:else} +
+ {#each pendingBills as bill, index} +
+
+
+
+ + {bill.category} + + + {bill.income_expense} + +
+

{bill.merchant}

+ {#if bill.description} +

{bill.description}

+ {/if} +
+

{bill.time}

+

¥{bill.amount.toFixed(2)}

+
+
+ +
+
+ {/each} +
+ {/if} + + {#if submitError} +
+ + {submitError} +
+ {/if} +
+ + + +
+
+ + + + + + + + 提交完成 + + + {#if submitResult} +
+
+ 成功创建 + {submitResult.success} 条 +
+ {#if submitResult.duplicates > 0} +
+ 重复跳过 + {submitResult.duplicates} 条 +
+ {/if} + {#if submitResult.failed > 0} +
+ 失败 + {submitResult.failed} 条 +
+ {/if} +
+ {/if} + + + +
+
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index e1cad20..495bf51 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -13,6 +13,8 @@ import TrendingDown from '@lucide/svelte/icons/trending-down'; import Loader2 from '@lucide/svelte/icons/loader-2'; import AlertCircle from '@lucide/svelte/icons/alert-circle'; + import Plus from '@lucide/svelte/icons/plus'; + import { goto } from '$app/navigation'; let isDragOver = $state(false); let selectedFile: File | null = $state(null); @@ -169,9 +171,15 @@
- - 上传账单 - 支持支付宝、微信账单 CSV 文件 + +
+ 上传账单 + 支持支付宝、微信账单 CSV 文件 +
+
diff --git a/web/src/routes/bills/+page.svelte b/web/src/routes/bills/+page.svelte index ccb04d7..87b38e9 100644 --- a/web/src/routes/bills/+page.svelte +++ b/web/src/routes/bills/+page.svelte @@ -1,5 +1,6 @@ @@ -144,25 +185,52 @@
- +
-

账单列表

-

查看和筛选已处理的账单记录

+

账单管理

+

查看、筛选和手动添加账单记录

+
+
+ {#if activeTab === 'list'} + + {/if}
-
- - {#if errorMessage} -
- - {errorMessage} -
- {/if} + +
+ + +
+ + + + + {#if activeTab === 'list'} +
+ + {#if errorMessage} +
+ + {errorMessage} +
+ {/if}
@@ -232,40 +300,50 @@
- + + + {filterCategory || '全部'} + + + + 全部 + {#each categories as cat} + {cat} + {/each} + + +
- + + + {filterIncomeExpense || '全部'} + + + + 全部 + 支出 + 收入 + + +
- + + + {filterBillType === 'alipay' ? '支付宝' : filterBillType === 'wechat' ? '微信' : filterBillType === 'manual' ? '手动' : '全部'} + + + + 全部 + 支付宝 + 微信 + 手动 + + +
@@ -310,8 +388,8 @@ {record.time} @@ -400,4 +478,11 @@ {/if} +
+ {/if} + + + {#if activeTab === 'manual'} + + {/if}