feat: 实现分类管理功能,包括数据模型、仓库层、服务层和数据库初始化脚本

This commit is contained in:
2026-01-28 09:55:29 +08:00
parent 1b6feae015
commit 4d024eba8e
6 changed files with 447 additions and 30 deletions

View File

@@ -0,0 +1,106 @@
package models
import (
"time"
"gorm.io/gorm"
)
// DefaultCategory represents a category template for new user initialization
// These templates are copied to users' categories table when they first access categories
type DefaultCategory struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:50;not null" json:"name"`
Icon string `gorm:"size:100" json:"icon"` // Iconify format: mdi:icon-name
Color string `gorm:"size:20" json:"color"` // HEX color code (e.g., #FF6B35)
Type CategoryType `gorm:"size:20;not null" json:"type"` // income or expense
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
SortOrder int `gorm:"default:0" json:"sort_order"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Relationships
Parent *DefaultCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Children []DefaultCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
}
// TableName specifies the table name for DefaultCategory
func (DefaultCategory) TableName() string {
return "default_categories"
}
// GetAllDefaultCategories retrieves all active default categories from the database
func GetAllDefaultCategories(db *gorm.DB) ([]DefaultCategory, error) {
var categories []DefaultCategory
err := db.Where("is_active = ?", true).Order("sort_order ASC").Find(&categories).Error
return categories, err
}
// GetDefaultCategoriesWithChildren retrieves all active root categories with their children
func GetDefaultCategoriesWithChildren(db *gorm.DB) ([]DefaultCategory, error) {
var categories []DefaultCategory
err := db.Where("is_active = ? AND parent_id IS NULL", true).
Preload("Children", "is_active = ?", true).
Order("sort_order ASC").
Find(&categories).Error
return categories, err
}
// CopyToUserCategories copies default categories to a user's categories
// Returns a map of old DefaultCategory ID to new Category ID for parent-child mapping
func CopyDefaultCategoriesToUser(db *gorm.DB, userID uint) error {
// Get all default categories
var defaults []DefaultCategory
if err := db.Where("is_active = ?", true).Order("sort_order ASC").Find(&defaults).Error; err != nil {
return err
}
if len(defaults) == 0 {
return nil // No default categories to copy
}
// Map to track old ID -> new ID for parent references
idMap := make(map[uint]uint)
// First pass: create all root categories (no parent)
for _, dc := range defaults {
if dc.ParentID == nil {
cat := Category{
UserID: userID,
Name: dc.Name,
Icon: dc.Icon,
Color: dc.Color,
Type: dc.Type,
ParentID: nil,
SortOrder: dc.SortOrder,
}
if err := db.Create(&cat).Error; err != nil {
return err
}
idMap[dc.ID] = cat.ID
}
}
// Second pass: create all child categories
for _, dc := range defaults {
if dc.ParentID != nil {
newParentID := idMap[*dc.ParentID]
cat := Category{
UserID: userID,
Name: dc.Name,
Icon: dc.Icon,
Color: dc.Color,
Type: dc.Type,
ParentID: &newParentID,
SortOrder: dc.SortOrder,
}
if err := db.Create(&cat).Error; err != nil {
return err
}
idMap[dc.ID] = cat.ID
}
}
return nil
}

View File

@@ -304,6 +304,7 @@ type Category struct {
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"size:50;not null" json:"name"`
Icon string `gorm:"size:50" json:"icon"`
Color string `gorm:"size:20" json:"color"` // HEX color code (e.g., #FF6B35)
Type CategoryType `gorm:"size:20;not null" json:"type"` // income or expense
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
SortOrder int `gorm:"default:0" json:"sort_order"`

View File

@@ -26,6 +26,11 @@ func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
return &CategoryRepository{db: db}
}
// GetDB returns the underlying database connection
func (r *CategoryRepository) GetDB() *gorm.DB {
return r.db
}
// Create creates a new category in the database
func (r *CategoryRepository) Create(category *models.Category) error {
if err := r.db.Create(category).Error; err != nil {

View File

@@ -24,6 +24,7 @@ type CategoryInput struct {
UserID uint `json:"user_id"`
Name string `json:"name" binding:"required"`
Icon string `json:"icon"`
Color string `json:"color"` // HEX color code (e.g., #FF6B35)
Type models.CategoryType `json:"type" binding:"required"`
ParentID *uint `json:"parent_id,omitempty"`
SortOrder int `json:"sort_order"`
@@ -70,6 +71,7 @@ func (s *CategoryService) CreateCategory(input CategoryInput) (*models.Category,
UserID: input.UserID,
Name: input.Name,
Icon: input.Icon,
Color: input.Color,
Type: input.Type,
ParentID: input.ParentID,
SortOrder: input.SortOrder,
@@ -340,102 +342,129 @@ func (s *CategoryService) GetCategoryPath(userID, id uint) ([]models.Category, e
return path, nil
}
// initDefaultCategories seeds default categories for a user
// initDefaultCategories seeds default categories for a user from the default_categories template table
// If no templates are found in the database, falls back to hardcoded defaults
func (s *CategoryService) initDefaultCategories(userID uint) error {
db := s.repo.GetDB()
// Try to copy from default_categories template table first
err := models.CopyDefaultCategoriesToUser(db, userID)
if err == nil {
// Check if any categories were actually created
count, countErr := s.repo.CountByType(userID, models.CategoryTypeExpense)
if countErr == nil && count > 0 {
return nil // Successfully copied from template
}
}
// Fallback to hardcoded defaults if template table is empty or failed
return s.initHardcodedDefaults(userID)
}
// initHardcodedDefaults seeds hardcoded default categories for a user (fallback)
func (s *CategoryService) initHardcodedDefaults(userID uint) error {
defaults := []struct {
Name string
Type models.CategoryType
Icon string
Color string
SortOrder int
Children []struct {
Name string
Type models.CategoryType
Icon string
Color string
SortOrder int
}
}{
// Expenses
{
Name: "餐饮", Type: models.CategoryTypeExpense, Icon: "restaurant", SortOrder: 1,
Name: "餐饮", Type: models.CategoryTypeExpense, Icon: "mdi:silverware-fork-knife", Color: "#FF6B35", SortOrder: 1,
Children: []struct {
Name string
Type models.CategoryType
Icon string
Color string
SortOrder int
}{
{Name: "早餐", Type: models.CategoryTypeExpense, Icon: "breakfast_dining", SortOrder: 1},
{Name: "午餐", Type: models.CategoryTypeExpense, Icon: "lunch_dining", SortOrder: 2},
{Name: "晚餐", Type: models.CategoryTypeExpense, Icon: "dinner_dining", SortOrder: 3},
{Name: "零食", Type: models.CategoryTypeExpense, Icon: "icecream", SortOrder: 4},
{Name: "饮料", Type: models.CategoryTypeExpense, Icon: "local_cafe", SortOrder: 5},
{Name: "早餐", Type: models.CategoryTypeExpense, Icon: "mdi:food-croissant", Color: "#FBBF24", SortOrder: 1},
{Name: "午餐", Type: models.CategoryTypeExpense, Icon: "mdi:food", Color: "#FB923C", SortOrder: 2},
{Name: "晚餐", Type: models.CategoryTypeExpense, Icon: "mdi:food-turkey", Color: "#F97316", SortOrder: 3},
{Name: "零食", Type: models.CategoryTypeExpense, Icon: "mdi:cookie", Color: "#FDE047", SortOrder: 4},
{Name: "饮料", Type: models.CategoryTypeExpense, Icon: "mdi:coffee", Color: "#A16207", SortOrder: 5},
},
},
{
Name: "交通", Type: models.CategoryTypeExpense, Icon: "directions_bus", SortOrder: 2,
Name: "交通", Type: models.CategoryTypeExpense, Icon: "mdi:bus", Color: "#3B82F6", SortOrder: 2,
Children: []struct {
Name string
Type models.CategoryType
Icon string
Color string
SortOrder int
}{
{Name: "地铁", Type: models.CategoryTypeExpense, Icon: "subway", SortOrder: 1},
{Name: "公交", Type: models.CategoryTypeExpense, Icon: "directions_bus", SortOrder: 2},
{Name: "打车", Type: models.CategoryTypeExpense, Icon: "local_taxi", SortOrder: 3},
{Name: "加油", Type: models.CategoryTypeExpense, Icon: "local_gas_station", SortOrder: 4},
{Name: "地铁", Type: models.CategoryTypeExpense, Icon: "mdi:subway-variant", Color: "#3B82F6", SortOrder: 1},
{Name: "公交", Type: models.CategoryTypeExpense, Icon: "mdi:bus", Color: "#60A5FA", SortOrder: 2},
{Name: "打车", Type: models.CategoryTypeExpense, Icon: "mdi:taxi", Color: "#FBBF24", SortOrder: 3},
{Name: "加油", Type: models.CategoryTypeExpense, Icon: "mdi:gas-station", Color: "#EF4444", SortOrder: 4},
},
},
{
Name: "购物", Type: models.CategoryTypeExpense, Icon: "shopping_bag", SortOrder: 3,
Name: "购物", Type: models.CategoryTypeExpense, Icon: "mdi:shopping", Color: "#EC4899", SortOrder: 3,
Children: []struct {
Name string
Type models.CategoryType
Icon string
Color string
SortOrder int
}{
{Name: "服饰", Type: models.CategoryTypeExpense, Icon: "checkroom", SortOrder: 1},
{Name: "日用", Type: models.CategoryTypeExpense, Icon: "soap", SortOrder: 2},
{Name: "电子数码", Type: models.CategoryTypeExpense, Icon: "devices", SortOrder: 3},
{Name: "服饰", Type: models.CategoryTypeExpense, Icon: "mdi:tshirt-crew", Color: "#EC4899", SortOrder: 1},
{Name: "日用", Type: models.CategoryTypeExpense, Icon: "mdi:basket", Color: "#F472B6", SortOrder: 2},
{Name: "电子数码", Type: models.CategoryTypeExpense, Icon: "mdi:laptop", Color: "#3B82F6", SortOrder: 3},
},
},
{
Name: "居住", Type: models.CategoryTypeExpense, Icon: "home", SortOrder: 4,
Name: "居住", Type: models.CategoryTypeExpense, Icon: "mdi:home", Color: "#92400E", SortOrder: 4,
Children: []struct {
Name string
Type models.CategoryType
Icon string
Color string
SortOrder int
}{
{Name: "房租", Type: models.CategoryTypeExpense, Icon: "house", SortOrder: 1},
{Name: "水电煤", Type: models.CategoryTypeExpense, Icon: "lightbulb", SortOrder: 2},
{Name: "物业", Type: models.CategoryTypeExpense, Icon: "security", SortOrder: 3},
{Name: "房租", Type: models.CategoryTypeExpense, Icon: "mdi:home-city", Color: "#92400E", SortOrder: 1},
{Name: "水电煤", Type: models.CategoryTypeExpense, Icon: "mdi:lightbulb-on", Color: "#FBBF24", SortOrder: 2},
{Name: "物业", Type: models.CategoryTypeExpense, Icon: "mdi:office-building", Color: "#64748B", SortOrder: 3},
},
},
{
Name: "娱乐", Type: models.CategoryTypeExpense, Icon: "sports_esports", SortOrder: 5,
Name: "娱乐", Type: models.CategoryTypeExpense, Icon: "mdi:gamepad-variant", Color: "#8B5CF6", SortOrder: 5,
Children: []struct {
Name string
Type models.CategoryType
Icon string
Color string
SortOrder int
}{
{Name: "游戏", Type: models.CategoryTypeExpense, Icon: "gamepad", SortOrder: 1},
{Name: "电影", Type: models.CategoryTypeExpense, Icon: "movie", SortOrder: 2},
{Name: "运动", Type: models.CategoryTypeExpense, Icon: "fitness_center", SortOrder: 3},
{Name: "游戏", Type: models.CategoryTypeExpense, Icon: "mdi:controller-classic", Color: "#8B5CF6", SortOrder: 1},
{Name: "电影", Type: models.CategoryTypeExpense, Icon: "mdi:movie-open", Color: "#EF4444", SortOrder: 2},
{Name: "运动", Type: models.CategoryTypeExpense, Icon: "mdi:dumbbell", Color: "#22C55E", SortOrder: 3},
},
},
// Income
{
Name: "收入", Type: models.CategoryTypeIncome, Icon: "payments", SortOrder: 10,
Name: "工作收入", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase", Color: "#10B981", SortOrder: 10,
Children: []struct {
Name string
Type models.CategoryType
Icon string
Color string
SortOrder int
}{
{Name: "工资", Type: models.CategoryTypeIncome, Icon: "work", SortOrder: 1},
{Name: "奖金", Type: models.CategoryTypeIncome, Icon: "star", SortOrder: 2},
{Name: "兼职", Type: models.CategoryTypeIncome, Icon: "access_time", SortOrder: 3},
{Name: "理财", Type: models.CategoryTypeIncome, Icon: "trending_up", SortOrder: 4},
{Name: "工资", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase", Color: "#10B981", SortOrder: 1},
{Name: "奖金", Type: models.CategoryTypeIncome, Icon: "mdi:trophy", Color: "#FBBF24", SortOrder: 2},
{Name: "兼职", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase-clock", Color: "#8B5CF6", SortOrder: 3},
{Name: "理财", Type: models.CategoryTypeIncome, Icon: "mdi:chart-line", Color: "#F59E0B", SortOrder: 4},
},
},
}
@@ -446,6 +475,7 @@ func (s *CategoryService) initDefaultCategories(userID uint) error {
Name: cat.Name,
Type: cat.Type,
Icon: cat.Icon,
Color: cat.Color,
SortOrder: cat.SortOrder,
}
if err := s.repo.Create(parent); err != nil {
@@ -458,6 +488,7 @@ func (s *CategoryService) initDefaultCategories(userID uint) error {
Name: child.Name,
Type: child.Type,
Icon: child.Icon,
Color: child.Color,
SortOrder: child.SortOrder,
ParentID: &parent.ID,
}