feat: 完善项目架构并增强分析页面功能

- 新增项目文档和 Docker 配置
  - 添加 README.md 和 TODO.md 项目文档
  - 为各服务添加 Dockerfile 和 docker-compose 配置

- 重构后端架构
  - 新增 adapter 层(HTTP/Python 适配器)
  - 新增 repository 层(数据访问抽象)
  - 新增 router 模块统一管理路由
  - 新增账单处理 handler

- 扩展前端 UI 组件库
  - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件
  - 集成 shadcn-svelte 组件库

- 增强分析页面功能
  - 添加时间范围筛选器(支持本月默认值)
  - 修复 DateRangePicker 默认值显示问题
  - 优化数据获取和展示逻辑

- 完善分析器服务
  - 新增 FastAPI 服务接口
  - 改进账单清理器实现
This commit is contained in:
2026-01-10 01:15:52 +08:00
parent 94f8ea12e6
commit 087ae027cc
96 changed files with 4301 additions and 482 deletions

165
server/handler/bills.go Normal file
View File

@@ -0,0 +1,165 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"billai-server/model"
"billai-server/repository"
)
// ListBillsRequest 账单列表请求参数
type ListBillsRequest struct {
Page int `form:"page"` // 页码,从 1 开始
PageSize int `form:"page_size"` // 每页数量,默认 20
StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD
EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD
Category string `form:"category"` // 分类筛选
Type string `form:"type"` // 账单类型 alipay/wechat
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
}
// ListBillsResponse 账单列表响应
type ListBillsResponse struct {
Result bool `json:"result"`
Message string `json:"message,omitempty"`
Data *ListBillsData `json:"data,omitempty"`
}
// ListBillsData 账单列表数据
type ListBillsData struct {
Total int64 `json:"total"` // 总记录数
TotalExpense float64 `json:"total_expense"` // 筛选条件下的总支出
TotalIncome float64 `json:"total_income"` // 筛选条件下的总收入
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页数量
Pages int `json:"pages"` // 总页数
Bills []model.CleanedBill `json:"bills"` // 账单列表
}
// ListBills 获取清洗后的账单列表
func ListBills(c *gin.Context) {
var req ListBillsRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, ListBillsResponse{
Result: false,
Message: "参数解析失败: " + err.Error(),
})
return
}
// 设置默认值
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100 // 限制最大每页数量
}
// 构建筛选条件
filter := make(map[string]interface{})
// 时间范围筛选
if req.StartDate != "" || req.EndDate != "" {
timeFilter := make(map[string]interface{})
if req.StartDate != "" {
startTime, err := time.Parse("2006-01-02", req.StartDate)
if err == nil {
timeFilter["$gte"] = startTime
}
}
if req.EndDate != "" {
endTime, err := time.Parse("2006-01-02", req.EndDate)
if err == nil {
// 结束日期包含当天,所以加一天
endTime = endTime.Add(24 * time.Hour)
timeFilter["$lt"] = endTime
}
}
if len(timeFilter) > 0 {
filter["time"] = timeFilter
}
}
// 分类筛选
if req.Category != "" {
filter["category"] = req.Category
}
// 账单类型筛选
if req.Type != "" {
filter["bill_type"] = req.Type
}
// 收支类型筛选
if req.IncomeExpense != "" {
filter["income_expense"] = req.IncomeExpense
}
// 获取数据
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusInternalServerError, ListBillsResponse{
Result: false,
Message: "数据库未连接",
})
return
}
// 获取账单列表(带分页)
bills, total, err := repo.GetCleanedBillsPaged(filter, req.Page, req.PageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, ListBillsResponse{
Result: false,
Message: "查询失败: " + err.Error(),
})
return
}
// 获取聚合统计
totalExpense, totalIncome, err := repo.GetBillsAggregate(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, ListBillsResponse{
Result: false,
Message: "统计失败: " + err.Error(),
})
return
}
// 计算总页数
pages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
pages++
}
c.JSON(http.StatusOK, ListBillsResponse{
Result: true,
Data: &ListBillsData{
Total: total,
TotalExpense: totalExpense,
TotalIncome: totalIncome,
Page: req.Page,
PageSize: req.PageSize,
Pages: pages,
Bills: bills,
},
})
}
// parsePageParam 解析分页参数
func parsePageParam(s string, defaultVal int) int {
if s == "" {
return defaultVal
}
val, err := strconv.Atoi(s)
if err != nil || val < 1 {
return defaultVal
}
return val
}

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -36,6 +35,23 @@ func Upload(c *gin.Context) {
req.Format = "csv"
}
// 验证 type 参数
if req.Type == "" {
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "请指定账单类型 (type: alipay 或 wechat)",
})
return
}
if req.Type != "alipay" && req.Type != "wechat" {
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "账单类型无效,仅支持 alipay 或 wechat",
})
return
}
billType := req.Type
// 3. 保存上传的文件
timestamp := time.Now().Format("20060102_150405")
inputFileName := fmt.Sprintf("%s_%s", timestamp, header.Filename)
@@ -64,9 +80,6 @@ func Upload(c *gin.Context) {
return
}
// 账单类型从去重结果获取
billType := dedupResult.BillType
fmt.Printf(" 原始记录: %d 条\n", dedupResult.OriginalCount)
if dedupResult.DuplicateCount > 0 {
fmt.Printf(" 重复记录: %d 条(已跳过)\n", dedupResult.DuplicateCount)
@@ -91,14 +104,14 @@ func Upload(c *gin.Context) {
// 使用去重后的文件路径进行后续处理
processFilePath := dedupResult.DedupFilePath
// 5. 构建输出文件路径
baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
// 5. 构建输出文件路径时间_type_编号
outputExt := ".csv"
if req.Format == "json" {
outputExt = ".json"
}
outputFileName := fmt.Sprintf("%s_%s_cleaned%s", timestamp, baseName, outputExt)
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
fileSeq := generateFileSequence(outputDirAbs, timestamp, billType, outputExt)
outputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, billType, fileSeq, outputExt)
outputPath := filepath.Join(outputDirAbs, outputFileName)
// 6. 执行 Python 清洗脚本
@@ -109,7 +122,7 @@ func Upload(c *gin.Context) {
End: req.End,
Format: req.Format,
}
cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
if cleanErr != nil {
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
@@ -118,12 +131,7 @@ func Upload(c *gin.Context) {
return
}
// 7. 如果去重检测没有识别出类型,从 Python 输出中检测
if billType == "" {
billType = cleanResult.BillType
}
// 8. 将去重后的原始数据存入 MongoDB原始数据集合
// 7. 将去重后的原始数据存入 MongoDB原始数据集合
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
if rawErr != nil {
fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr)
@@ -163,3 +171,14 @@ func Upload(c *gin.Context) {
},
})
}
// generateFileSequence 生成文件序号
// 根据当前目录下同一时间戳和类型的文件数量生成序号
func generateFileSequence(dir, timestamp, billType, ext string) string {
pattern := fmt.Sprintf("%s_%s_*%s", timestamp, billType, ext)
matches, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil || len(matches) == 0 {
return "001"
}
return fmt.Sprintf("%03d", len(matches)+1)
}