diff --git a/code/allapp/allapp-go/.idea/data_source_mapping.xml b/code/allapp/allapp-go/.idea/data_source_mapping.xml index 1c9b86ee..e007eda8 100644 --- a/code/allapp/allapp-go/.idea/data_source_mapping.xml +++ b/code/allapp/allapp-go/.idea/data_source_mapping.xml @@ -4,6 +4,7 @@ + \ No newline at end of file diff --git a/code/allapp/allapp-go/.idea/sqldialects.xml b/code/allapp/allapp-go/.idea/sqldialects.xml index e19bb375..79e0bd3d 100644 --- a/code/allapp/allapp-go/.idea/sqldialects.xml +++ b/code/allapp/allapp-go/.idea/sqldialects.xml @@ -3,6 +3,7 @@ + diff --git a/code/allapp/allapp-go/api/handler/finance/category.go b/code/allapp/allapp-go/api/handler/finance/category.go new file mode 100644 index 00000000..df3d1270 --- /dev/null +++ b/code/allapp/allapp-go/api/handler/finance/category.go @@ -0,0 +1,200 @@ +package finance + +import ( + "allapp/api/router/middleware" + "allapp/api/types" + "allapp/db" + "allapp/db/repo" + "allapp/utils/errorx" + "allapp/utils/requestx" + "allapp/utils/responsex" + "allapp/utils/uniqueid" + + "github.com/gofiber/fiber/v3" +) + +// ListFinanceCategories 查看记账分类 +func ListFinanceCategories(c fiber.Ctx) error { + spaceId, err := middleware.GetSpaceID(c) + + if err != nil { + return err + } + + categories, err := db.Queries.ListFinanceCategories(c.Context(), spaceId) + + if err != nil { + return err + } + + financeCategories := make([]types.FinanceCategory, 0, len(categories)) + + for _, category := range categories { + financeCategories = append(financeCategories, types.FinanceCategory{ + Id: category.ID, + Name: category.Name, + Icon: category.Icon, + Type: category.Type, + SortNumber: category.SortNumber, + }) + } + + vo := new(types.ListFinanceCategoriesVO) + vo.Categories = financeCategories + + return responsex.OK(c, vo) +} + +// CreateFinanceCategory 新建分类 +func CreateFinanceCategory(c fiber.Ctx) error { + dto := new(types.CreateFinanceCategoryDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + userId, err := middleware.GetUserID(c) + if err != nil { + return err + } + + spaceID, err2 := middleware.GetSpaceID(c) + if err2 != nil { + return err2 + } + + _, err = db.Queries.GetFinanceCategoryByName(c.Context(), repo.GetFinanceCategoryByNameParams{ + SpaceID: spaceID, + Name: dto.Name, + Type: dto.Type, + }) + + isFound := true + + if err != nil { + if errorx.IsNotFound(err) { + isFound = false + } else { + return err + } + } + + if isFound { + return responsex.Error(c, "分类名称不能重复") + } + + nextId := uniqueid.NextId() + + err = db.Queries.InsertFinanceCategory(c.Context(), repo.InsertFinanceCategoryParams{ + ID: nextId, + SpaceID: spaceID, + Name: dto.Name, + Icon: dto.Icon, + Type: dto.Type, + SortNumber: dto.SortNumber, + CreateBy: userId, + UpdateBy: userId, + }) + + if err != nil { + return err + } + + vo := new(types.CreateFinanceCategoryVO) + vo.Id = nextId + + return responsex.OK(c, vo) +} + +// UpdateFinanceCategorySortNumber 更新记账分类顺序 +func UpdateFinanceCategorySortNumber(c fiber.Ctx) error { + dto := new(types.UpdateFinanceCategorySortDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + userId, err := middleware.GetUserID(c) + if err != nil { + return err + } + + // 组装批量更新参数 + params := make([]repo.BatchUpdateFinanceCategorySortNumberParams, 0, len(dto.Categories)) + for _, datum := range dto.Categories { + params = append(params, repo.BatchUpdateFinanceCategorySortNumberParams{ + ID: datum.Id, + SortNumber: datum.SortNumber, + UpdateBy: userId, + }) + } + + // 使用统一事务封装 + err = db.WithTx(c.Context(), func(q *repo.Queries) error { + batch := q.BatchUpdateFinanceCategorySortNumber(c.Context(), params) + + if err := db.RunBatch(c.Context(), batch); err != nil { + return err + } + + return nil + }) + + if err != nil { + return err + } + + return responsex.OK(c, nil) +} + +// DeleteFinanceCategory 删除记账分类 +func DeleteFinanceCategory(c fiber.Ctx) error { + dto := new(types.DeleteFinanceCategoryDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + err := db.WithTx(c.Context(), func(q *repo.Queries) error { + err := q.DeleteFinanceCategory(c.Context(), dto.Id) + if err != nil { + return err + } + + err = q.DeleteFinanceRecordByCategoryId(c.Context(), dto.Id) + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return err + } + + return responsex.OK(c, nil) +} + +// UpdateFinanceCategory 更新记账分类 +func UpdateFinanceCategory(c fiber.Ctx) error { + dto := new(types.UpdateFinanceCategoryDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + userId, err := middleware.GetUserID(c) + if err != nil { + return err + } + + err = db.Queries.UpdateFinanceCategory(c.Context(), repo.UpdateFinanceCategoryParams{ + ID: dto.Id, + Name: dto.Name, + Icon: dto.Icon, + UpdateBy: userId, + }) + + if err != nil { + return err + } + + return responsex.OK(c, nil) +} diff --git a/code/allapp/allapp-go/api/handler/finance/record.go b/code/allapp/allapp-go/api/handler/finance/record.go new file mode 100644 index 00000000..d811bfc5 --- /dev/null +++ b/code/allapp/allapp-go/api/handler/finance/record.go @@ -0,0 +1,163 @@ +package finance + +import ( + "allapp/api/router/middleware" + "allapp/api/types" + "allapp/db" + "allapp/db/repo" + "allapp/utils/pgtypex" + "allapp/utils/requestx" + "allapp/utils/responsex" + "allapp/utils/timex" + "allapp/utils/uniqueid" + "time" + + "github.com/gofiber/fiber/v3" +) + +// ListFinanceRecords 记账记录 +func ListFinanceRecords(c fiber.Ctx) error { + dto := new(types.ListFinanceRecordsDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + spaceID, err := middleware.GetSpaceID(c) + if err != nil { + return err + } + + // 解析年月(yyyy-MM) + startTime, err := timex.ParseTimeFlexible(dto.StartTime) + if err != nil { + return err + } + + endTime, err := timex.ParseTimeFlexible(dto.EndTime) + if err != nil { + return err + } + + // 开始时间兜底 + if startTime == nil { + t := time.Unix(0, 0) + startTime = &t + } + + // 结束时间兜底 + if endTime == nil { + t := time.Now().AddDate(10, 0, 0) + endTime = &t + } + + categoryId := dto.CategoryId + + records, err := db.Queries.ListFinanceRecords(c.Context(), repo.ListFinanceRecordsParams{ + SpaceID: spaceID, + RecordTime: pgtypex.TimePtrToPgDate(startTime), + RecordTime_2: pgtypex.TimePtrToPgDate(endTime), + Column4: categoryId, + }) + + if err != nil { + return err + } + + financeRecords := make([]types.FinanceRecord, 0, len(records)) + + for _, record := range records { + financeRecords = append(financeRecords, types.FinanceRecord{ + Id: record.ID, + CategoryId: record.CategoryID, + Amount: pgtypex.NumericToString(record.Amount), + Remark: pgtypex.TextToString(record.Remark), + RecordTime: pgtypex.DateToString(record.RecordTime), + MemberId: record.UpdateBy, + MemberName: pgtypex.TextToString(record.MemberName), + }) + } + + vo := new(types.ListFinanceRecordsVO) + vo.Records = financeRecords + + return responsex.OK(c, vo) +} + +// CreateFinanceRecord 新增记账记录 +func CreateFinanceRecord(c fiber.Ctx) error { + dto := new(types.CreateFinanceRecordDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + spaceID, err := middleware.GetSpaceID(c) + if err != nil { + return err + } + + userID, err := middleware.GetUserID(c) + if err != nil { + return err + } + + err = db.Queries.InsertFinanceRecord(c.Context(), repo.InsertFinanceRecordParams{ + ID: uniqueid.NextId(), + SpaceID: spaceID, + CategoryID: dto.CategoryId, + Amount: pgtypex.StringToNumeric(dto.Amount), + RecordTime: pgtypex.StringToDate(dto.RecordTime), + Remark: pgtypex.StringToText(dto.Remark), + CreateBy: userID, + UpdateBy: userID, + }) + + if err != nil { + return err + } + + return responsex.OK(c, nil) +} + +// UpdateFinanceRecord 更新记账记录 +func UpdateFinanceRecord(c fiber.Ctx) error { + dto := new(types.UpdateFinanceRecordDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + userID, err := middleware.GetUserID(c) + if err != nil { + return err + } + + err = db.Queries.UpdateFinanceRecord(c.Context(), repo.UpdateFinanceRecordParams{ + ID: uniqueid.NextId(), + CategoryID: dto.CategoryId, + Amount: pgtypex.StringToNumeric(dto.Amount), + Remark: pgtypex.StringToText(dto.Remark), + RecordTime: pgtypex.StringToDate(dto.RecordTime), + UpdateBy: userID, + }) + + if err != nil { + return err + } + + return responsex.OK(c, nil) +} + +// DeleteFinanceRecord 删除记账记录 +func DeleteFinanceRecord(c fiber.Ctx) error { + dto := new(types.DeleteFinanceRecordDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + err := db.Queries.DeleteFinanceRecord(c.Context(), dto.Id) + + if err != nil { + return err + } + + return responsex.OK(c, nil) +} diff --git a/code/allapp/allapp-go/api/handler/space/member.go b/code/allapp/allapp-go/api/handler/space/member.go new file mode 100644 index 00000000..2567b9b3 --- /dev/null +++ b/code/allapp/allapp-go/api/handler/space/member.go @@ -0,0 +1,127 @@ +package space + +import ( + "allapp/api/router/middleware" + "allapp/api/types" + "allapp/db" + "allapp/db/repo" + "allapp/utils/requestx" + "allapp/utils/responsex" + + "github.com/gofiber/fiber/v3" +) + +// UpdateSpaceDefault 更新空间默认情况 +func UpdateSpaceDefault(c fiber.Ctx) error { + dto := new(types.UpdateSpaceIsDefaultDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + userId, err := middleware.GetUserID(c) + if err != nil { + return err + } + + err = db.WithTx(c.Context(), func(q *repo.Queries) error { + err = q.UpdateSpaceIsDefault(c.Context(), repo.UpdateSpaceIsDefaultParams{ + SpaceID: dto.SpaceId, + UserID: userId, + IsDefault: dto.IsDefault, + }) + + if err != nil { + return err + } + + var otherDefault int16 + + if dto.IsDefault == 1 { + otherDefault = 0 + } + + err = q.UpdateOtherSpacesIsDefault(c.Context(), repo.UpdateOtherSpacesIsDefaultParams{ + IsDefault: otherDefault, + UserID: userId, + SpaceID: dto.SpaceId, + }) + + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return err + } + + return responsex.OK(c, "更新成功") +} + +// UpdateMemberName 更新群成员名称 +func UpdateMemberName(c fiber.Ctx) error { + dto := new(types.UpdateMemberNameDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + userId, err := middleware.GetUserID(c) + if err != nil { + return err + } + + err = db.Queries.UpdateSpaceMemberName(c.Context(), repo.UpdateSpaceMemberNameParams{ + SpaceID: dto.SpaceId, + UserID: userId, + MemberName: dto.MemberName, + }) + + if err != nil { + return err + } + + return responsex.OK(c, "更新成功") +} + +// UpdateSpaceSortNumber 更新空间排序 +func UpdateSpaceSortNumber(c fiber.Ctx) error { + dto := new(types.UpdateSpaceSortNumberDTO) + if err := requestx.BindAndValidate(c, dto); err != nil { + return err + } + + userId, err := middleware.GetUserID(c) + if err != nil { + return err + } + + // 组装批量更新参数 + params := make([]repo.BatchUpdateSpaceSortNumberParams, 0, len(dto.Spaces)) + + for _, datum := range dto.Spaces { + params = append(params, repo.BatchUpdateSpaceSortNumberParams{ + SortNumber: datum.SortNumber, + SpaceID: datum.SpaceId, + UserID: userId, + }) + } + + // 使用统一事务封装 + err = db.WithTx(c.Context(), func(q *repo.Queries) error { + batch := q.BatchUpdateSpaceSortNumber(c.Context(), params) + + if err := db.RunBatch(c.Context(), batch); err != nil { + return err + } + + return nil + }) + + if err != nil { + return err + } + + return responsex.OK(c, "更新成功") +} diff --git a/code/allapp/allapp-go/api/handler/space/index.go b/code/allapp/allapp-go/api/handler/space/space.go similarity index 86% rename from code/allapp/allapp-go/api/handler/space/index.go rename to code/allapp/allapp-go/api/handler/space/space.go index b4effdf4..b949dbed 100644 --- a/code/allapp/allapp-go/api/handler/space/index.go +++ b/code/allapp/allapp-go/api/handler/space/space.go @@ -228,80 +228,6 @@ func ExitSpace(c fiber.Ctx) error { return responsex.OK(c, "空间已成功退出") } -// UpdateMemberName 更新群成员名称 -func UpdateMemberName(c fiber.Ctx) error { - dto := new(types.UpdateMemberNameDTO) - if err := requestx.BindAndValidate(c, dto); err != nil { - return err - } - - userId, err := middleware.GetUserID(c) - if err != nil { - return err - } - - err = db.Queries.UpdateSpaceMemberName(c.Context(), repo.UpdateSpaceMemberNameParams{ - SpaceID: dto.SpaceId, - UserID: userId, - MemberName: dto.MemberName, - }) - - if err != nil { - return err - } - - return responsex.OK(c, "更新成功") -} - -// UpdateSpaceDefault 更新空间默认情况 -func UpdateSpaceDefault(c fiber.Ctx) error { - dto := new(types.UpdateSpaceIsDefaultDTO) - if err := requestx.BindAndValidate(c, dto); err != nil { - return err - } - - userId, err := middleware.GetUserID(c) - if err != nil { - return err - } - - err = db.WithTx(c.Context(), func(q *repo.Queries) error { - err = q.UpdateSpaceIsDefault(c.Context(), repo.UpdateSpaceIsDefaultParams{ - SpaceID: dto.SpaceId, - UserID: userId, - IsDefault: dto.IsDefault, - }) - - if err != nil { - return err - } - - var otherDefault int16 - - if dto.IsDefault == 1 { - otherDefault = 0 - } - - err = q.UpdateOtherSpacesIsDefault(c.Context(), repo.UpdateOtherSpacesIsDefaultParams{ - IsDefault: otherDefault, - UserID: userId, - SpaceID: dto.SpaceId, - }) - - if err != nil { - return err - } - - return nil - }) - - if err != nil { - return err - } - - return responsex.OK(c, "更新成功") -} - // UpdateSpaceName 更新空间名称 func UpdateSpaceName(c fiber.Ctx) error { dto := new(types.UpdateSpaceNameDTO) @@ -381,47 +307,6 @@ func GetSpaceSettings(c fiber.Ctx) error { return responsex.OK(c, vo) } -// UpdateSpaceSortNumber 更新空间排序 -func UpdateSpaceSortNumber(c fiber.Ctx) error { - dto := new(types.UpdateSpaceSortNumberDTO) - if err := requestx.BindAndValidate(c, dto); err != nil { - return err - } - - userId, err := middleware.GetUserID(c) - if err != nil { - return err - } - - // 组装批量更新参数 - params := make([]repo.BatchUpdateSpaceSortNumberParams, 0, len(dto.Spaces)) - - for _, datum := range dto.Spaces { - params = append(params, repo.BatchUpdateSpaceSortNumberParams{ - SortNumber: datum.SortNumber, - SpaceID: datum.SpaceId, - UserID: userId, - }) - } - - // 使用统一事务封装 - err = db.WithTx(c.Context(), func(q *repo.Queries) error { - batch := q.BatchUpdateSpaceSortNumber(c.Context(), params) - - if err := db.RunBatch(c.Context(), batch); err != nil { - return err - } - - return nil - }) - - if err != nil { - return err - } - - return responsex.OK(c, "更新成功") -} - func ListSpaceMembers(c fiber.Ctx) error { dto := new(types.ListSpaceMembersDTO) if err := requestx.BindAndValidate(c, dto); err != nil { diff --git a/code/allapp/allapp-go/api/router/router.go b/code/allapp/allapp-go/api/router/router.go index 40d068d1..04a4aaa4 100644 --- a/code/allapp/allapp-go/api/router/router.go +++ b/code/allapp/allapp-go/api/router/router.go @@ -2,6 +2,7 @@ package router import ( "allapp/api/handler/auth" + "allapp/api/handler/finance" "allapp/api/handler/space" "allapp/api/router/middleware" "allapp/config" @@ -39,6 +40,19 @@ func SetupRouters(app *fiber.App) { spaceRouter.Post("/member/name", space.UpdateMemberName) spaceRouter.Post("/member/sortNumber", space.UpdateSpaceSortNumber) + // 记账操作 + financeRouter := api.Group("/finance").Use(middleware.Auth()).Use(middleware.SpaceGuard()) + financeRouter.Post("/category/list", finance.ListFinanceCategories) + financeRouter.Post("/category/create", finance.CreateFinanceCategory) + financeRouter.Post("/category/sortNumber", finance.UpdateFinanceCategorySortNumber) + financeRouter.Post("/category/delete", finance.DeleteFinanceCategory) + financeRouter.Post("/category/update", finance.UpdateFinanceCategory) + + financeRouter.Post("/record/list", finance.ListFinanceRecords) + financeRouter.Post("/record/create", finance.CreateFinanceRecord) + financeRouter.Post("/record/update", finance.UpdateFinanceRecord) + financeRouter.Post("/record/delete", finance.DeleteFinanceRecord) + // authGroup.Get("/user/profile", user.GetProfile) // spaceGroup.Get("/space/info", space.GetInfo) } diff --git a/code/allapp/allapp-go/api/types/finance.go b/code/allapp/allapp-go/api/types/finance.go new file mode 100644 index 00000000..b2be4fa4 --- /dev/null +++ b/code/allapp/allapp-go/api/types/finance.go @@ -0,0 +1,95 @@ +package types + +// =================================== 记账分类 =================================== + +// FinanceCategory 记账分类 +type FinanceCategory struct { + Id int64 `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Type int16 `json:"type"` + SortNumber int32 `json:"sort_number"` +} + +type ListFinanceCategoriesVO struct { + Categories []FinanceCategory `json:"categories"` +} + +// CreateFinanceCategoryDTO 创建记账分类 +type CreateFinanceCategoryDTO struct { + Name string `json:"name" validate:"required"` + Icon string `json:"icon" validate:"required"` + Type int16 `json:"type" validate:"required"` + SortNumber int32 `json:"sort_number" validate:"required"` +} + +type CreateFinanceCategoryVO struct { + Id int64 `json:"id"` +} + +// UpdateFinanceCategorySortItem 更新记账分类排序 +type UpdateFinanceCategorySortItem struct { + Id int64 `json:"id" validate:"required"` + SortNumber int32 `json:"sort_number" validate:"required"` +} + +type UpdateFinanceCategorySortDTO struct { + Categories []UpdateFinanceCategorySortItem `json:"categories" validate:"required,dive"` +} + +// DeleteFinanceCategoryDTO 删除记账分类 +type DeleteFinanceCategoryDTO struct { + Id int64 `json:"id" validate:"required"` +} + +type UpdateFinanceCategoryDTO struct { + Id int64 `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Icon string `json:"icon" validate:"required"` +} + +// =================================== 记账分类 =================================== + +// =================================== 记账 =================================== + +type FinanceRecord struct { + Id int64 `json:"id"` + CategoryId int64 `json:"category_id"` + Amount string `json:"amount"` + Remark string `json:"remark"` + RecordTime string `json:"record_time"` + MemberId int64 `json:"member_id"` + MemberName string `json:"member_name"` +} + +type ListFinanceRecordsDTO struct { + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CategoryId int64 `json:"category_id"` +} + +type ListFinanceRecordsVO struct { + Records []FinanceRecord `json:"records"` +} + +// CreateFinanceRecordDTO 新建记账 +type CreateFinanceRecordDTO struct { + CategoryId int64 `json:"category_id" validate:"required"` + Amount string `json:"amount" validate:"required"` + RecordTime string `json:"record_time" validate:"required"` + Remark string `json:"remark"` +} + +// UpdateFinanceRecordDTO 更新记账 +type UpdateFinanceRecordDTO struct { + Id int64 `json:"id" validate:"required"` + CategoryId int64 `json:"category_id" validate:"required"` + Amount string `json:"amount" validate:"required"` + RecordTime string `json:"record_time" validate:"required"` + Remark string `json:"remark"` +} + +// DeleteFinanceRecordDTO 删除记账 +type DeleteFinanceRecordDTO struct { + Id int64 `json:"id" validate:"required"` +} diff --git a/code/allapp/allapp-go/db/queries/finance_category_space.sql b/code/allapp/allapp-go/db/queries/finance_category_space.sql index a46c93a6..f0c88aaa 100644 --- a/code/allapp/allapp-go/db/queries/finance_category_space.sql +++ b/code/allapp/allapp-go/db/queries/finance_category_space.sql @@ -1,3 +1,26 @@ -- name: BatchInsertFinanceCategorySpace :copyfrom INSERT INTO finance_category_space (id, space_id, name, icon, type, sort_number,create_by,update_by) -VALUES ($1, $2, $3, $4, $5, $6,$7,$8); \ No newline at end of file +VALUES ($1, $2, $3, $4, $5, $6,$7,$8); + +-- name: ListFinanceCategories :many +SELECT * FROM finance_category_space WHERE space_id = $1; + +-- name: GetFinanceCategoryByName :one +SELECT * FROM finance_category_space WHERE space_id = $1 AND name = $2 AND type = $3; + +-- name: InsertFinanceCategory :exec +INSERT INTO finance_category_space(id,space_id,name,icon,type,sort_number,create_by,update_by,create_time,update_time) +values ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW()); + +-- name: BatchUpdateFinanceCategorySortNumber :batchexec +UPDATE finance_category_space +SET sort_number = $1,update_by = $2,update_time = NOW() +WHERE id = $3; + +-- name: DeleteFinanceCategory :exec +DELETE FROM finance_category_space WHERE id = $1; + +-- name: UpdateFinanceCategory :exec +UPDATE finance_category_space +SET name = $1,icon = $2,update_by = $3,update_time = NOW() +WHERE id = $4; \ No newline at end of file diff --git a/code/allapp/allapp-go/db/queries/finance_record.sql b/code/allapp/allapp-go/db/queries/finance_record.sql new file mode 100644 index 00000000..0d996237 --- /dev/null +++ b/code/allapp/allapp-go/db/queries/finance_record.sql @@ -0,0 +1,40 @@ +-- name: ListFinanceRecords :many +SELECT + fr.*, + su.member_name +FROM finance_record fr + LEFT JOIN space_member su + ON fr.space_id = su.space_id + AND fr.update_by = su.user_id +WHERE + fr.space_id = $1 + AND fr.record_time >= $2 + AND fr.record_time < $3 + AND ( + $4 = 0 + OR fr.category_id = $4 + ) +ORDER BY + fr.record_time DESC, + fr.create_time DESC; + +-- name: InsertFinanceRecord :exec +INSERT INTO finance_record(id,category_id,space_id,amount,record_time,remark,create_by,update_by,create_time,update_time) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW()); + +-- name: UpdateFinanceRecord :exec +UPDATE finance_record +SET + category_id = $2, + amount = $3, + remark = $4, + record_time = $5, + update_by = $6, + update_time = NOW() +WHERE id = $1; + +-- name: DeleteFinanceRecord :exec +DELETE FROM finance_record WHERE id = $1; + +-- name: DeleteFinanceRecordByCategoryId :exec +DELETE FROM finance_record WHERE category_id = $1; \ No newline at end of file diff --git a/code/allapp/allapp-go/db/repo/batch.go b/code/allapp/allapp-go/db/repo/batch.go index 702371db..3ea9b5a8 100644 --- a/code/allapp/allapp-go/db/repo/batch.go +++ b/code/allapp/allapp-go/db/repo/batch.go @@ -16,6 +16,59 @@ var ( ErrBatchAlreadyClosed = errors.New("batch already closed") ) +const batchUpdateFinanceCategorySortNumber = `-- name: BatchUpdateFinanceCategorySortNumber :batchexec +UPDATE finance_category_space +SET sort_number = $1,update_by = $2,update_time = NOW() +WHERE id = $3 +` + +type BatchUpdateFinanceCategorySortNumberBatchResults struct { + br pgx.BatchResults + tot int + closed bool +} + +type BatchUpdateFinanceCategorySortNumberParams struct { + SortNumber int32 + UpdateBy int64 + ID int64 +} + +func (q *Queries) BatchUpdateFinanceCategorySortNumber(ctx context.Context, arg []BatchUpdateFinanceCategorySortNumberParams) *BatchUpdateFinanceCategorySortNumberBatchResults { + batch := &pgx.Batch{} + for _, a := range arg { + vals := []interface{}{ + a.SortNumber, + a.UpdateBy, + a.ID, + } + batch.Queue(batchUpdateFinanceCategorySortNumber, vals...) + } + br := q.db.SendBatch(ctx, batch) + return &BatchUpdateFinanceCategorySortNumberBatchResults{br, len(arg), false} +} + +func (b *BatchUpdateFinanceCategorySortNumberBatchResults) Exec(f func(int, error)) { + defer b.br.Close() + for t := 0; t < b.tot; t++ { + if b.closed { + if f != nil { + f(t, ErrBatchAlreadyClosed) + } + continue + } + _, err := b.br.Exec() + if f != nil { + f(t, err) + } + } +} + +func (b *BatchUpdateFinanceCategorySortNumberBatchResults) Close() error { + b.closed = true + return b.br.Close() +} + const batchUpdateSpaceSortNumber = `-- name: BatchUpdateSpaceSortNumber :batchexec UPDATE space_member SET sort_number = $1 diff --git a/code/allapp/allapp-go/db/repo/finance_category_space.sql.go b/code/allapp/allapp-go/db/repo/finance_category_space.sql.go index 5d9735b4..c4f2a32e 100644 --- a/code/allapp/allapp-go/db/repo/finance_category_space.sql.go +++ b/code/allapp/allapp-go/db/repo/finance_category_space.sql.go @@ -5,6 +5,10 @@ package repo +import ( + "context" +) + type BatchInsertFinanceCategorySpaceParams struct { ID int64 SpaceID int64 @@ -15,3 +19,128 @@ type BatchInsertFinanceCategorySpaceParams struct { CreateBy int64 UpdateBy int64 } + +const deleteFinanceCategory = `-- name: DeleteFinanceCategory :exec +DELETE FROM finance_category_space WHERE id = $1 +` + +func (q *Queries) DeleteFinanceCategory(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteFinanceCategory, id) + return err +} + +const getFinanceCategoryByName = `-- name: GetFinanceCategoryByName :one +SELECT id, space_id, name, icon, type, sort_number, create_by, create_time, update_by, update_time FROM finance_category_space WHERE space_id = $1 AND name = $2 AND type = $3 +` + +type GetFinanceCategoryByNameParams struct { + SpaceID int64 + Name string + Type int16 +} + +func (q *Queries) GetFinanceCategoryByName(ctx context.Context, arg GetFinanceCategoryByNameParams) (FinanceCategorySpace, error) { + row := q.db.QueryRow(ctx, getFinanceCategoryByName, arg.SpaceID, arg.Name, arg.Type) + var i FinanceCategorySpace + err := row.Scan( + &i.ID, + &i.SpaceID, + &i.Name, + &i.Icon, + &i.Type, + &i.SortNumber, + &i.CreateBy, + &i.CreateTime, + &i.UpdateBy, + &i.UpdateTime, + ) + return i, err +} + +const insertFinanceCategory = `-- name: InsertFinanceCategory :exec +INSERT INTO finance_category_space(id,space_id,name,icon,type,sort_number,create_by,update_by,create_time,update_time) +values ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW()) +` + +type InsertFinanceCategoryParams struct { + ID int64 + SpaceID int64 + Name string + Icon string + Type int16 + SortNumber int32 + CreateBy int64 + UpdateBy int64 +} + +func (q *Queries) InsertFinanceCategory(ctx context.Context, arg InsertFinanceCategoryParams) error { + _, err := q.db.Exec(ctx, insertFinanceCategory, + arg.ID, + arg.SpaceID, + arg.Name, + arg.Icon, + arg.Type, + arg.SortNumber, + arg.CreateBy, + arg.UpdateBy, + ) + return err +} + +const listFinanceCategories = `-- name: ListFinanceCategories :many +SELECT id, space_id, name, icon, type, sort_number, create_by, create_time, update_by, update_time FROM finance_category_space WHERE space_id = $1 +` + +func (q *Queries) ListFinanceCategories(ctx context.Context, spaceID int64) ([]FinanceCategorySpace, error) { + rows, err := q.db.Query(ctx, listFinanceCategories, spaceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FinanceCategorySpace + for rows.Next() { + var i FinanceCategorySpace + if err := rows.Scan( + &i.ID, + &i.SpaceID, + &i.Name, + &i.Icon, + &i.Type, + &i.SortNumber, + &i.CreateBy, + &i.CreateTime, + &i.UpdateBy, + &i.UpdateTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateFinanceCategory = `-- name: UpdateFinanceCategory :exec +UPDATE finance_category_space +SET name = $1,icon = $2,update_by = $3,update_time = NOW() +WHERE id = $4 +` + +type UpdateFinanceCategoryParams struct { + Name string + Icon string + UpdateBy int64 + ID int64 +} + +func (q *Queries) UpdateFinanceCategory(ctx context.Context, arg UpdateFinanceCategoryParams) error { + _, err := q.db.Exec(ctx, updateFinanceCategory, + arg.Name, + arg.Icon, + arg.UpdateBy, + arg.ID, + ) + return err +} diff --git a/code/allapp/allapp-go/db/repo/finance_record.sql.go b/code/allapp/allapp-go/db/repo/finance_record.sql.go new file mode 100644 index 00000000..fae9f4d7 --- /dev/null +++ b/code/allapp/allapp-go/db/repo/finance_record.sql.go @@ -0,0 +1,172 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: finance_record.sql + +package repo + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteFinanceRecord = `-- name: DeleteFinanceRecord :exec +DELETE FROM finance_record WHERE id = $1 +` + +func (q *Queries) DeleteFinanceRecord(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteFinanceRecord, id) + return err +} + +const deleteFinanceRecordByCategoryId = `-- name: DeleteFinanceRecordByCategoryId :exec +DELETE FROM finance_record WHERE category_id = $1 +` + +func (q *Queries) DeleteFinanceRecordByCategoryId(ctx context.Context, categoryID int64) error { + _, err := q.db.Exec(ctx, deleteFinanceRecordByCategoryId, categoryID) + return err +} + +const insertFinanceRecord = `-- name: InsertFinanceRecord :exec +INSERT INTO finance_record(id,category_id,space_id,amount,record_time,remark,create_by,update_by,create_time,update_time) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW()) +` + +type InsertFinanceRecordParams struct { + ID int64 + CategoryID int64 + SpaceID int64 + Amount pgtype.Numeric + RecordTime pgtype.Date + Remark pgtype.Text + CreateBy int64 + UpdateBy int64 +} + +func (q *Queries) InsertFinanceRecord(ctx context.Context, arg InsertFinanceRecordParams) error { + _, err := q.db.Exec(ctx, insertFinanceRecord, + arg.ID, + arg.CategoryID, + arg.SpaceID, + arg.Amount, + arg.RecordTime, + arg.Remark, + arg.CreateBy, + arg.UpdateBy, + ) + return err +} + +const listFinanceRecords = `-- name: ListFinanceRecords :many +SELECT + fr.id, fr.category_id, fr.space_id, fr.amount, fr.remark, fr.record_time, fr.create_by, fr.create_time, fr.update_by, fr.update_time, + su.member_name +FROM finance_record fr + LEFT JOIN space_member su + ON fr.space_id = su.space_id + AND fr.update_by = su.user_id +WHERE + fr.space_id = $1 + AND fr.record_time >= $2 + AND fr.record_time < $3 + AND ( + $4 = 0 + OR fr.category_id = $4 + ) +ORDER BY + fr.record_time DESC, + fr.create_time DESC +` + +type ListFinanceRecordsParams struct { + SpaceID int64 + RecordTime pgtype.Date + RecordTime_2 pgtype.Date + Column4 interface{} +} + +type ListFinanceRecordsRow struct { + ID int64 + CategoryID int64 + SpaceID int64 + Amount pgtype.Numeric + Remark pgtype.Text + RecordTime pgtype.Date + CreateBy int64 + CreateTime pgtype.Timestamptz + UpdateBy int64 + UpdateTime pgtype.Timestamptz + MemberName pgtype.Text +} + +func (q *Queries) ListFinanceRecords(ctx context.Context, arg ListFinanceRecordsParams) ([]ListFinanceRecordsRow, error) { + rows, err := q.db.Query(ctx, listFinanceRecords, + arg.SpaceID, + arg.RecordTime, + arg.RecordTime_2, + arg.Column4, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListFinanceRecordsRow + for rows.Next() { + var i ListFinanceRecordsRow + if err := rows.Scan( + &i.ID, + &i.CategoryID, + &i.SpaceID, + &i.Amount, + &i.Remark, + &i.RecordTime, + &i.CreateBy, + &i.CreateTime, + &i.UpdateBy, + &i.UpdateTime, + &i.MemberName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateFinanceRecord = `-- name: UpdateFinanceRecord :exec +UPDATE finance_record +SET + category_id = $2, + amount = $3, + remark = $4, + record_time = $5, + update_by = $6, + update_time = NOW() +WHERE id = $1 +` + +type UpdateFinanceRecordParams struct { + ID int64 + CategoryID int64 + Amount pgtype.Numeric + Remark pgtype.Text + RecordTime pgtype.Date + UpdateBy int64 +} + +func (q *Queries) UpdateFinanceRecord(ctx context.Context, arg UpdateFinanceRecordParams) error { + _, err := q.db.Exec(ctx, updateFinanceRecord, + arg.ID, + arg.CategoryID, + arg.Amount, + arg.Remark, + arg.RecordTime, + arg.UpdateBy, + ) + return err +} diff --git a/code/allapp/allapp-go/db/repo/models.go b/code/allapp/allapp-go/db/repo/models.go index 8c602775..d1916094 100644 --- a/code/allapp/allapp-go/db/repo/models.go +++ b/code/allapp/allapp-go/db/repo/models.go @@ -31,6 +31,19 @@ type FinanceCategorySpace struct { UpdateTime pgtype.Timestamptz } +type FinanceRecord struct { + ID int64 + CategoryID int64 + SpaceID int64 + Amount pgtype.Numeric + Remark pgtype.Text + RecordTime pgtype.Date + CreateBy int64 + CreateTime pgtype.Timestamptz + UpdateBy int64 + UpdateTime pgtype.Timestamptz +} + type Space struct { ID int64 Name string diff --git a/code/allapp/allapp-go/db/schema.sql b/code/allapp/allapp-go/db/schema.sql index f9b48b4e..42277ddf 100644 --- a/code/allapp/allapp-go/db/schema.sql +++ b/code/allapp/allapp-go/db/schema.sql @@ -66,6 +66,7 @@ create table space_member unique (space_id, user_id) ); +--记账分类默认表 create table finance_category_default ( id bigint not null @@ -78,6 +79,7 @@ create table finance_category_default update_time timestamp with time zone default now() ); +--记账分类空间表 create table finance_category_space ( id bigint not null @@ -93,4 +95,19 @@ create table finance_category_space update_time timestamp with time zone default now() ); +-- 记账记录表 +create table finance_record +( + id bigint not null + primary key, + category_id bigint not null, + space_id bigint not null, + amount numeric(12, 2) not null, + remark varchar(255), + record_time date, + create_by bigint not null, + create_time timestamp with time zone default now(), + update_by bigint not null, + update_time timestamp with time zone default now() +); diff --git a/code/allapp/allapp-go/utils/timex/index.go b/code/allapp/allapp-go/utils/timex/index.go new file mode 100644 index 00000000..a1a6fe66 --- /dev/null +++ b/code/allapp/allapp-go/utils/timex/index.go @@ -0,0 +1,29 @@ +package timex + +import ( + "fmt" + "time" +) + +func ParseTimeFlexible(value string) (*time.Time, error) { + if value == "" { + return nil, nil // ⭐ 空字符串 → 不限时间 + } + + layouts := []string{ + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", + "2006-01", + time.RFC3339, + } + + for _, layout := range layouts { + t, err := time.Parse(layout, value) + if err == nil { + return &t, nil + } + } + + return nil, fmt.Errorf("unsupported time format: %s", value) +} diff --git a/code/allapp/allapp-rn/src/app/(tabs)/_layout.tsx b/code/allapp/allapp-rn/src/app/(tabs)/_layout.tsx index c853301e..b6414efc 100644 --- a/code/allapp/allapp-rn/src/app/(tabs)/_layout.tsx +++ b/code/allapp/allapp-rn/src/app/(tabs)/_layout.tsx @@ -3,7 +3,6 @@ import type { NavItem } from '@/store/navigation' import { Slot, usePathname, useRouter } from 'expo-router' import { useCallback, useEffect, useState } from 'react' import { Text, TouchableOpacity, View } from 'react-native' -import { KeyboardAvoidingView } from 'react-native-keyboard-controller' import Animated, { useAnimatedRef } from 'react-native-reanimated' import Sortable from 'react-native-sortables' import { ADrawer, LucideIcon } from '@/components/common/ao' @@ -72,14 +71,13 @@ export default function TabsLayout() { return ( - - + diff --git a/code/allapp/allapp-rn/src/app/(tabs)/money/components/CategorySheet.tsx b/code/allapp/allapp-rn/src/app/(tabs)/finance/components/CategorySheet.tsx similarity index 94% rename from code/allapp/allapp-rn/src/app/(tabs)/money/components/CategorySheet.tsx rename to code/allapp/allapp-rn/src/app/(tabs)/finance/components/CategorySheet.tsx index 83368594..3521c251 100644 --- a/code/allapp/allapp-rn/src/app/(tabs)/money/components/CategorySheet.tsx +++ b/code/allapp/allapp-rn/src/app/(tabs)/finance/components/CategorySheet.tsx @@ -1,4 +1,4 @@ -import type { MoneyCategoryItem } from '@/types/money/category' +import type { FinanceCategory } from '@/types/finance/category' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AInput, LucideIcon } from '@/components/common/ao' import EmptyState from '@/components/common/EmptyStatus' @@ -16,15 +16,15 @@ import { interface CategorySection { title: string - data: MoneyCategoryItem[] + data: FinanceCategory[] } interface CategorySheetProps { open?: boolean - moneyCategories?: MoneyCategoryItem[] + moneyCategories?: FinanceCategory[] value?: number | null onClose?: () => void - onSelect?: (category: MoneyCategoryItem) => void + onSelect?: (category: FinanceCategory) => void } export default function CategorySheet({ @@ -45,7 +45,7 @@ export default function CategorySheet({ } | null>(null) const handleSelect = useCallback( - (item: MoneyCategoryItem) => { + (item: FinanceCategory) => { onSelect?.(item) onClose?.() }, @@ -56,7 +56,7 @@ export default function CategorySheet({ id: number, name: string, type: number, - ): MoneyCategoryItem { + ): FinanceCategory { return { id, name, @@ -188,9 +188,9 @@ export default function CategorySheet({ }, 0) }} keyExtractor={(item, index) => - String((item as MoneyCategoryItem).id ?? index)} + String((item as FinanceCategory).id ?? index)} renderItem={({ item }) => { - const category = item as MoneyCategoryItem + const category = item as FinanceCategory const selected = value === category.id return ( diff --git a/code/allapp/allapp-rn/src/app/(tabs)/money/components/TransactionsList.tsx b/code/allapp/allapp-rn/src/app/(tabs)/finance/components/TransactionsList.tsx similarity index 96% rename from code/allapp/allapp-rn/src/app/(tabs)/money/components/TransactionsList.tsx rename to code/allapp/allapp-rn/src/app/(tabs)/finance/components/TransactionsList.tsx index 9788068f..42323ad1 100644 --- a/code/allapp/allapp-rn/src/app/(tabs)/money/components/TransactionsList.tsx +++ b/code/allapp/allapp-rn/src/app/(tabs)/finance/components/TransactionsList.tsx @@ -1,7 +1,6 @@ -import type { MoneyRecord, MoneyRecordGroup } from '@/types/money/record' +import type { FinanceRecord, FinanceRecordGroup } from '@/types/finance/record' import React, { useCallback, useState } from 'react' import { Pressable, RefreshControl, SectionList, Text, Vibration, View } from 'react-native' - import { LucideIcon } from '@/components/common/ao' import EmptyState from '@/components/common/EmptyStatus' import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon' @@ -11,8 +10,8 @@ import { useUserStore } from '@/store/user' import { MoneyCategoryEnum } from '@/types/money/category' interface StaticTransactionsProps { - records: MoneyRecordGroup[] - onUpdate?: (data: MoneyRecord) => void + records: FinanceRecordGroup[] + onUpdate?: (data: FinanceRecord) => void onDelete?: (id: number) => void onDetail?: (id: number) => void onRefreshData?: () => void @@ -163,9 +162,9 @@ export default function StaticTransactions({ {item.remark ? item.remark : category?.name} { - item.user_id !== userID && ( + item.member_id !== userID && ( - {item.username} + {item.member_name} ) } @@ -186,7 +185,7 @@ export default function StaticTransactions({ }} > { - userID === item.user_id && ( + userID === item.member_id && ( <> { if (res.isSuccess) { - moneyRecordStore.setRecords(res.data.records || []) + financeStore.setRecords(res.data.records || []) } else { toast.show({ @@ -58,9 +58,11 @@ export default function Money() { console.log(err) }) } + useFocusEffect( useCallback(() => { - fetchMoneyRecords() + financeStore.ensureCategoriesLoaded() + fetchFinanceRecords() }, [searchParams.startTime, searchParams.endTime]), ) @@ -69,7 +71,7 @@ export default function Money() { id, }) - fetchMoneyRecords() + fetchFinanceRecords() } function timeModeTip() { @@ -147,7 +149,7 @@ export default function Money() { numberOfLines={1} ellipsizeMode="tail" > - {moneyRecordStore.getTotalIncome()} + {financeStore.getTotalIncome()} @@ -158,7 +160,7 @@ export default function Money() { numberOfLines={1} ellipsizeMode="tail" > - {moneyRecordStore.getTotalExpense()} + {financeStore.getTotalExpense()} @@ -169,7 +171,7 @@ export default function Money() { numberOfLines={1} ellipsizeMode="tail" > - {moneyRecordStore.getTotalBalance()} + {financeStore.getTotalBalance()} @@ -178,16 +180,16 @@ export default function Money() { { - router.navigate({ - pathname: '/money/record/editor', + router.push({ + pathname: '/finance/record/editor', params: { id: data.id }, }) }} onDetail={(id) => { router.navigate({ - pathname: '/money/record/detail', + pathname: '/finance/record/detail', params: { id }, }) }} @@ -195,7 +197,7 @@ export default function Money() { handleDeleteRecord(id) }} onRefreshData={() => { - fetchMoneyRecords() + fetchFinanceRecords() }} /> @@ -203,7 +205,7 @@ export default function Money() { { - router.navigate('/money/record/editor') + router.navigate('/finance/record/editor') }} > @@ -247,7 +249,7 @@ export default function Money() { { setShowCategorySheet(false) }} @@ -283,7 +285,8 @@ export default function Money() { icon="LayoutGrid" text="类别设置" onPress={() => { - router.navigate('/money/category/setting') + // router.navigate('/money/category/setting') + router.push('/finance/category') setShowActionsMenu(false) }} /> diff --git a/code/allapp/allapp-rn/src/app/(tabs)/index.tsx b/code/allapp/allapp-rn/src/app/(tabs)/index.tsx index ffb25217..4fe80fcc 100644 --- a/code/allapp/allapp-rn/src/app/(tabs)/index.tsx +++ b/code/allapp/allapp-rn/src/app/(tabs)/index.tsx @@ -26,21 +26,21 @@ export default function Index() { {/* ('/money')} + onPress={() => ('/finance')} > 开始使用 */} ('/money')} + onPress={() => ('/finance')} > 创建空间 ('/money')} + onPress={() => ('/finance')} > 加入空间 diff --git a/code/allapp/allapp-rn/src/app/finance/category/components/EditCategory.tsx b/code/allapp/allapp-rn/src/app/finance/category/components/EditCategory.tsx new file mode 100644 index 00000000..a06e5f7c --- /dev/null +++ b/code/allapp/allapp-rn/src/app/finance/category/components/EditCategory.tsx @@ -0,0 +1,197 @@ +import type { LegendListRef } from '@legendapp/list' +import type { FinanceCategory } from '@/types/finance/category' +import { LegendList } from '@legendapp/list' +import { useEffect, useRef, useState } from 'react' +import { Pressable, Text, View } from 'react-native' +import { AButton, AInput } from '@/components/common/ao' +import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon' +import { + Actionsheet, + ActionsheetBackdrop, + ActionsheetContent, + ActionsheetDragIndicator, + ActionsheetDragIndicatorWrapper, +} from '@/components/ui/actionsheet' +import { useFinanceStore } from '@/store/finance' +import { FinanceCategoryEnum } from '@/types/finance/category' + +export interface EditCategoryForm { + id?: number + name: string + icon: string +} + +interface EditCategoryProps { + open: boolean + onClose?: () => void + type: FinanceCategoryEnum + category: FinanceCategory | null + onConfirm?: (form: EditCategoryForm) => Promise +} + +export default function EditCategory({ + open, + onClose, + type, + onConfirm, + category, +}: EditCategoryProps) { + const financeStore = useFinanceStore() + + const [isOpen, setIsOpen] = useState(open) + const [form, setForm] = useState({ icon: '', name: '' }) + const [confirmDisabled, setConfirmDisabled] = useState(true) + const [confirmLoading, setConfirmLoading] = useState(false) + const listRef = useRef(null) + + useEffect(() => { + setIsOpen(open) + if (!open) + setForm({ icon: '', name: '' }) + }, [open]) + + // 确认按钮状态 + useEffect(() => { + setConfirmDisabled(!(form.name && form.name.length <= 5 && form.icon)) + }, [form.name, form.icon]) + + useEffect(() => { + if (!form.icon || !listRef.current) { + return + } + + const index = financeStore.getDefaultGroupRow(form.icon) + + if (index !== -1) { + listRef.current.scrollToIndex({ + index, + viewPosition: 0.5, + animated: true, + }) + } + }, [form.icon]) + + // 编辑已有分类时填充数据 + useEffect(() => { + if (category) + setForm(category) + }, [category]) + + function handleClose() { + setForm({ icon: '', name: '' }) + setIsOpen(false) + onClose?.() + } + + return ( + + + + + + + + + + 取消 + + + {type === FinanceCategoryEnum.Expense ? '添加支出分类' : '添加收入分类'} + + { + if (!onConfirm) + return + try { + setConfirmLoading(true) + await onConfirm({ ...form, name: form.name.trim() }) + } + finally { + setConfirmLoading(false) + } + }} + > + 确认 + + + + setForm(prevForm => ({ ...prevForm, name: text }))} + /> + String(item.index)} + recycleItems + initialContainerPoolRatio={20} + style={{ + maxHeight: 400, + }} + initialScrollIndex={financeStore.getDefaultGroupRow(category?.icon || '')} + renderItem={({ item }) => ( + + { + item.type === 'title' + ? ( + {item.value} + ) + : ( + + {item.value.map((icon) => { + const selected = form.icon === icon + + return ( + { + setForm(prevForm => ({ + ...prevForm, + icon, + })) + }} + > + + + + + ) + })} + + ) + } + + )} + /> + + + + + ) +} diff --git a/code/allapp/allapp-rn/src/app/finance/category/index.tsx b/code/allapp/allapp-rn/src/app/finance/category/index.tsx new file mode 100644 index 00000000..0f7ea71c --- /dev/null +++ b/code/allapp/allapp-rn/src/app/finance/category/index.tsx @@ -0,0 +1,303 @@ +import type { SortableGridRenderItem } from 'react-native-sortables' +import type { EditCategoryForm } from './components/EditCategory' +import type { FinanceCategory } from '@/types/finance/category' +import { useCallback, useEffect, useState } from 'react' +import { Pressable, Text, Vibration, View } from 'react-native' +import Animated, { useAnimatedRef } from 'react-native-reanimated' +import Sortable from 'react-native-sortables' +import { toast } from 'sonner-native' +import { AButton, ATab, LucideIcon } from '@/components/common/ao' +import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon' +import CustomHeader from '@/components/custom/Header' +import { Spinner } from '@/components/ui/spinner' +import { createFinanceCategoryApi, deleteFinanceCategoryApi, updateFinanceCategoryApi } from '@/request/api/finance/category' +import { useFinanceStore } from '@/store/finance' +import { useMoneyCategoryStore } from '@/store/moneyCategory' +import { useSpaceStore } from '@/store/space' +import { FinanceCategoryEnum } from '@/types/finance/category' +import EditCategory from './components/EditCategory' + +export default function MoneyCategorySetting() { + const spaceStore = useSpaceStore() + const financeStore = useFinanceStore() + const moneyCategoryStore = useMoneyCategoryStore() + + const [selectedTab, setSelectedTab] = useState(FinanceCategoryEnum.Expense) + const scrollableRef = useAnimatedRef() + + const [categories, setCategories] = useState([]) + + const [draggingId, setDraggingId] = useState('') + const [draggingDeleteZone, setDraggingDeleteZone] = useState(false) + const [deleteTimer, setDeleteTimer] = useState(null) + const [deleteLoading, setDeleteLoading] = useState(false) + + const [showEditCategory, setshowEditCategory] = useState(false) + + const [selectedCategory, setSelectedCategory] = useState(null) + + useEffect(() => { + setCategories(selectedTab === FinanceCategoryEnum.Expense ? financeStore.getExpenseCategories() : financeStore.getIncomeCategories()) + }, [selectedTab, financeStore.categories]) + + const renderItem = useCallback>( + ({ item }) => ( + { + setSelectedCategory(item) + setshowEditCategory(true) + }} + className="flex flex-col items-center justify-center" + > + + + + + {item.name} + + + ), + [], + ) + + async function handleConfirmEdit(form: EditCategoryForm) { + if (form.name === '') { + toast.warning('分类名称不能为空') + return + } + if (form.icon === '') { + toast.warning('分类图标不能为空') + return + } + + const isDuplicate = moneyCategoryStore.isDuplicateCategory( + form.name, + selectedTab, + form.id ?? null, + ) + + if (isDuplicate) { + toast.warning('类别不能重复') + return + } + + if (form.id != null) { + const currentCategory = financeStore.getCategoryById(form.id) + + if (currentCategory === null) { + toast.warning('分类无效') + return + } + + await updateFinanceCategoryApi({ + id: form.id, + name: form.name, + icon: form.icon, + }).then((res) => { + if (res.isSuccess) { + currentCategory.name = form.name + currentCategory.icon = form.icon + + financeStore.updateCategory(currentCategory) + } + }) + } + else { + const nextSortNumber = financeStore.getCategoryNextSortNumber(selectedTab) + + await createFinanceCategoryApi({ + name: form.name, + icon: form.icon, + sort_number: nextSortNumber, + type: selectedTab, + }).then((res) => { + if (res.isSuccess) { + const newCategory = { + id: res.data.id, + name: form.name, + icon: form.icon, + sort_number: nextSortNumber, + type: selectedTab, + space_id: spaceStore.currentSpace?.space_id || 0, + } + + financeStore.createCategory(newCategory) + } + }) + + scrollableRef.current?.scrollToEnd({ animated: true }) + } + + setshowEditCategory(false) + } + + return ( + + + + + + { + setSelectedTab(key as FinanceCategoryEnum) + }} + data={[ + { + key: FinanceCategoryEnum.Expense, + label: '支出', + }, + { + key: FinanceCategoryEnum.Income, + label: '收入', + }, + ]} + /> + + } + onPress={(() => + setshowEditCategory(true) + )} + > + 新增 + + + + + + + + { + Vibration.vibrate(50) + setDraggingId(data.key) + }} + onDragEnd={(dragData) => { + setDraggingId('') + if (draggingDeleteZone) + return + + const newData = dragData.data + + if (JSON.stringify(categories) !== JSON.stringify(newData)) { + moneyCategoryStore.updateSortNumbers(newData, selectedTab) + } + }} + /> + + + { + if (!draggingId) + return + + setDraggingDeleteZone(true) + Vibration.vibrate(50) + + const timer = setTimeout(async () => { + const deleteId = Number(draggingId) + + setDeleteLoading(true) + + try { + const res = await deleteFinanceCategoryApi({ id: deleteId }) + + if (!res.isSuccess) { + throw new Error(res.message) + } + + financeStore.deleteCategory(deleteId) + + setDraggingId('') + setDraggingDeleteZone(false) + } + finally { + setDeleteLoading(false) + setDeleteTimer(null) + } + }, 1000) + + setDeleteTimer(timer) + }} + onItemLeave={() => { + setDraggingDeleteZone(false) + if (deleteTimer) { + clearTimeout(deleteTimer) + setDeleteTimer(null) + } + }} + onItemDrop={() => { + setDraggingDeleteZone(false) + if (deleteTimer) { + clearTimeout(deleteTimer) + setDeleteTimer(null) + } + }} + minActivationDistance={40} + > + + { + deleteLoading + ? ( + + ) + : ( + + ) + } + + {draggingDeleteZone ? '松手删除(将删除该类下所有记录)' : '拖到这里删除'} + + + + + + + + + { + setshowEditCategory(false) + setSelectedCategory(null) + }} + category={selectedCategory} + onConfirm={handleConfirmEdit} + /> + + ) +} diff --git a/code/allapp/allapp-rn/src/app/finance/record/editor/components/AmountKeyboard.tsx b/code/allapp/allapp-rn/src/app/finance/record/editor/components/AmountKeyboard.tsx new file mode 100644 index 00000000..20e03858 --- /dev/null +++ b/code/allapp/allapp-rn/src/app/finance/record/editor/components/AmountKeyboard.tsx @@ -0,0 +1,367 @@ +import Big from 'big.js' +import { useEffect, useState } from 'react' +import { Keyboard, Pressable, StyleSheet, Text, TouchableWithoutFeedback, View } from 'react-native' +import { ADatePicker, AInput, LucideIcon } from '@/components/common/ao/index' +import { useKeyboardHeight } from '@/hooks/useKeyboardHeight' +import { getCurrentDate } from '@/utils/time' + +interface KeydownItem { + key: string | number + content: React.ReactNode +} + +const keys: KeydownItem[] = [ + { key: '1', content: 1 }, + { key: '2', content: 2 }, + { key: '3', content: 3 }, + { key: 'del', content: }, + { key: '4', content: 4 }, + { key: '5', content: 5 }, + { key: '6', content: 6 }, + { key: '+', content: }, + { key: '7', content: 7 }, + { key: '8', content: 8 }, + { key: '9', content: 9 }, + { key: '-', content: }, + { key: 'clear', content: ( + + 重置 + + ) }, + { key: '0', content: 0 }, + { key: '.', content: }, + { key: '/', content: ( + + 完成 + + ) }, +] + +interface AmountKeyboardProps { + show?: boolean + record_time?: string + amount?: string + remark?: string + + // 回调:当键盘输入或修改数据时触发 + onChange?: (data: { record_time: string, amount: string, remark: string }) => void + + // 可选:点击完成按钮触发 + onDone?: () => void +} + +export default function AmountKeyboard({ + show = false, + record_time = '', + amount = '', + remark = '', + onChange, + onDone, +}: AmountKeyboardProps) { + // 自定义键盘计算过程 + const [keydownInput, setKeydownInput] = useState('') + // 日期选择 + const [showDatePicker, setShowDatePicker] = useState(false) + // 备注聚焦 + const [remarkIsFocused, setRemarkIsFocused] = useState(false) + // 键盘高度 + const keyboardHeight = useKeyboardHeight() + + // 使用一个对象管理三个字段 + const [internalData, setInternalData] = useState({ + record_time: record_time || getCurrentDate(), + amount: amount || '0', + remark: remark || '', + }) + + // 如果父组件 props 变化,需要同步 + useEffect(() => { + setInternalData({ + record_time: record_time || getCurrentDate(), + amount: amount || '0', + remark: remark || '', + }) + }, [record_time, amount, remark]) + + useEffect(() => { + if (!keydownInput) { + handleChange({ amount: '0' }) + return + } + + try { + const computed = calculateWithBig(keydownInput) + handleChange({ amount: computed }) + } + catch { + handleChange({ amount: '0' }) + } + }, [keydownInput]) + + const handleChange = (newData: Partial) => { + const updated = { ...internalData, ...newData } + setInternalData(updated) + onChange?.(updated) + } + + // 计算过程 + const handlePress = (key: string | number) => { + setKeydownInput((prev) => { + let newInput = prev + + if (key === 'del') { + newInput = prev.slice(0, -1) + } + else if (key === 'clear') { + setInternalData({ + record_time: getCurrentDate(), + amount: '0', + remark: '', + }) + return '' + } + else if (key === '/') { + onDone?.() + return prev + } + else { + const keyStr = key.toString() + + // 检查是否要输入运算符(+ 或 -) + if (keyStr === '+' || keyStr === '-') { + // 如果当前输入为空,不允许以运算符开头 + if (prev === '') { + return prev + } + + // 获取最后一个字符 + const lastChar = prev.slice(-1) + + // 如果最后一个字符已经是运算符,则替换而不是追加 + if (lastChar === '+' || lastChar === '-') { + newInput = prev.slice(0, -1) + keyStr + } + // 如果最后一个字符是小数点,不允许在小数点后直接加运算符 + else if (lastChar === '.') { + return prev + } + else { + newInput = prev + keyStr + } + } + // 检查是否要输入小数点(.) + else if (keyStr === '.') { + // 如果当前输入为空,不允许以小数点开头,改为输入"0." + if (prev === '') { + newInput = '0.' + } + else { + // 获取最后一个字符 + const lastChar = prev.slice(-1) + + // 如果最后一个字符是运算符,则需要在运算符后添加"0."而不是单独的"." + if (lastChar === '+' || lastChar === '-') { + newInput = `${prev}0.` + } + else { + // 检查当前数字是否已经包含小数点 + const parts = prev.split(/[+-]/) + const currentNumber = parts[parts.length - 1] + + // 如果当前数字中还没有小数点,可以添加 + if (!currentNumber.includes('.')) { + newInput = `${prev}.` + } + else { + // 已经有小数点了,不重复添加 + newInput = prev + } + } + } + } + // 处理数字输入 + else if (/^\d$/.test(keyStr)) { + // 检查当前数字的小数位数 + const parts = prev.split(/[+-]/) + const currentNumber = parts[parts.length - 1] + + // 如果当前数字包含小数点,检查小数位数 + if (currentNumber.includes('.')) { + const decimalParts = currentNumber.split('.') + // 如果小数部分已经达到2位,不允许继续输入 + if (decimalParts.length > 1 && decimalParts[1].length >= 2) { + newInput = prev // 不改变输入 + } + else { + newInput = prev + keyStr + } + } + else { + // 没有小数点,正常输入数字 + newInput = prev + keyStr + } + } + else { + // 对于其他字符,正常追加 + newInput = prev + keyStr + } + } + + return newInput + }) + } + + function calculateWithBig(expression: string): string { + if (!expression.trim()) + return '0' + + // 移除表达式末尾的运算符(+或-),避免不完整的表达式 + let cleanExpression = expression.trim() + const lastChar = cleanExpression.slice(-1) + if (lastChar === '+' || lastChar === '-') { + cleanExpression = cleanExpression.slice(0, -1) + } + + if (!cleanExpression) + return '0' + + try { + // 使用更智能的解析方式,支持基本的加减运算 + const numbers = cleanExpression.split(/[+-]/).filter(Boolean) + const operators = cleanExpression.split(/[^+-]/).filter(Boolean) + + if (numbers.length === 0) + return '0' + + let result = new Big(numbers[0]) + + for (let i = 0; i < operators.length; i++) { + const operator = operators[i] + const nextNumber = new Big(numbers[i + 1]) + + if (operator === '+') { + result = result.plus(nextNumber) + } + else if (operator === '-') { + result = result.minus(nextNumber) + } + } + + // 限制结果为最多两位小数 + return result.toFixed(2) + } + catch (error) { + // 如果解析失败,尝试只计算数字部分 + const tokens = expression.match(/[+-]?\d+(\.\d+)?/g) + if (!tokens || tokens.length === 0) + return '0' + + let total = new Big(0) + tokens.forEach((token) => { + total = total.plus(new Big(token)) + }) + + // 限制结果为最多两位小数 + return total.toFixed(2) + } + } + + if (!show) { + return null + } + + return ( + + + + + setShowDatePicker(true)} + > + + {internalData.record_time} + + + {internalData.amount} + + + {keydownInput} + handleChange({ remark: val })} + onFocus={() => setRemarkIsFocused(true)} + onBlur={() => setRemarkIsFocused(false)} + /> + + + + {keys.map((item, idx) => { + const isFirstCol = idx % 4 === 0 + const isFirstRow = idx < 4 + const isLastCol = (idx + 1) % 4 === 0 + const isLastRow = idx >= 12 + + return ( + handlePress(item.key)} + style={{ + width: '25%', + height: '25%', + justifyContent: 'center', + alignItems: 'center', + borderTopWidth: isFirstRow ? 0.3 : 0, + borderLeftWidth: isFirstCol ? 0.3 : 0, + borderRightWidth: !isLastCol ? 0.3 : 0, + borderBottomWidth: !isLastRow ? 0.3 : 0, + borderColor: '#ddd', + }} + > + {({ pressed }) => { + const defaultBg = item.key === '/' ? '#e6d7b8' : '#fff' + const pressedBg = item.key === '/' ? '#f5e9d4' : '#f2f2f2' + + return ( + <> + + {item.content} + + ) + }} + + ) + })} + + {remarkIsFocused && keyboardHeight > 220 && ( + + )} + + { + setShowDatePicker(false) + }} + onConfirm={(date) => { + setShowDatePicker(false) + handleChange({ record_time: date }) + }} + /> + + ) +} diff --git a/code/allapp/allapp-rn/src/app/finance/record/editor/components/CategoryGrid.tsx b/code/allapp/allapp-rn/src/app/finance/record/editor/components/CategoryGrid.tsx new file mode 100644 index 00000000..423f3f4b --- /dev/null +++ b/code/allapp/allapp-rn/src/app/finance/record/editor/components/CategoryGrid.tsx @@ -0,0 +1,96 @@ +import type { ViewStyle } from 'react-native' +import type { FinanceCategory } from '@/types/finance/category' +import React, { useEffect, useRef } from 'react' +import { FlatList, Pressable, Text, View } from 'react-native' +import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon' + +interface CategoryGridProps { + data: FinanceCategory[] + selectedCategory?: FinanceCategory | null + onSelectCategory?: (item: FinanceCategory | null) => void + + className?: string + style?: ViewStyle +} + +export default function CategoryGrid({ + data, + selectedCategory, + onSelectCategory, + className, + style, +}: CategoryGridProps) { + const NUM_COLUMNS = 4 + + const flatListRef = useRef>(null) + + // 自动滚动到选中行 + useEffect(() => { + if (!selectedCategory || !flatListRef.current) + return + + const itemIndex = data.findIndex(item => item.id === selectedCategory.id) + if (itemIndex === -1 || !flatListRef.current) + return + + setTimeout(() => { + if (flatListRef.current) { + flatListRef.current.scrollToIndex({ + index: Math.floor(itemIndex / 4), + animated: true, + viewPosition: 0.5, + }) + } + }, 0) + }, [selectedCategory]) + + return ( + item.id.toString()} + showsVerticalScrollIndicator={false} + className={`${className}`} + style={[style]} + getItemLayout={(data, index) => { + return { length: 80, offset: 80 * index, index } + }} + renderItem={({ item }) => { + const isSelected = selectedCategory?.id === item.id + + return ( + onSelectCategory?.(item)} + > + + + + + {item.name} + + + ) + }} + /> + ) +} diff --git a/code/allapp/allapp-rn/src/app/finance/record/editor/index.tsx b/code/allapp/allapp-rn/src/app/finance/record/editor/index.tsx new file mode 100644 index 00000000..2871d104 --- /dev/null +++ b/code/allapp/allapp-rn/src/app/finance/record/editor/index.tsx @@ -0,0 +1,188 @@ +import type { FinanceCategory } from '@/types/finance/category' +import type { FinanceRecord } from '@/types/finance/record' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { useEffect, useState } from 'react' +import { Text, TouchableOpacity, View } from 'react-native' +import { toast } from 'sonner-native' +import { LucideIcon } from '@/components/common/ao' +import CustomHeader from '@/components/custom/Header' +import PagerTabs from '@/components/custom/PagerTabs' +import { createFinanceRecordApi, updateFinanceRecordApi } from '@/request/api/finance/record' +import { useFinanceStore } from '@/store/finance' +import { getCurrentDate, toDateString } from '@/utils/time' +import AmountKeyboard from './components/AmountKeyboard' +import CategoryGrid from './components/CategoryGrid' + +export default function MoenyRecordEditor() { + const financeStore = useFinanceStore() + + const router = useRouter() + const expense = financeStore.getExpenseCategories() + const income = financeStore.getIncomeCategories() + + const { id: updateId } = useLocalSearchParams<{ id: string }>() + // 自定义键盘 + const [showKeydown, setShowKeydown] = useState(false) + // 选中分类 + const [selectedCategory, setSelectedCategory] = useState(null) + // 记账记录 + const defaultFinanceRecord: FinanceRecord = { + id: -1, + category_id: -1, + amount: '0.00', + remark: '', + record_time: getCurrentDate(), + member_id: -1, + member_name: '', + } + + const [financeRecord, setFinanceRecord] = useState(defaultFinanceRecord) + + useEffect(() => { + if (updateId) { + const updateData = financeStore.getRecordById(Number(updateId)) + if (updateData !== null) { + setFinanceRecord({ + ...updateData, + record_time: toDateString(updateData.record_time), + }) + setShowKeydown(true) + + const moneyCategory = financeStore.getCategoryById(updateData.category_id) + + if (moneyCategory != null) { + setSelectedCategory(moneyCategory) + } + } + } + }, []) + + // 保存记录 + function handleSaveRecord() { + if (selectedCategory === null) { + toast.warning('请选择分类') + return + } + + if (financeRecord.amount === '0.00') { + toast.warning('请输入金额') + return + } + + const amount = financeRecord.amount.replace(/^-+/, '') + + if (updateId) { + updateFinanceRecordApi({ + id: financeRecord.id, + category_id: selectedCategory.id, + amount, + remark: financeRecord.remark, + record_time: financeRecord.record_time, + }).then((res) => { + if (res.isSuccess) { + router.back() + } + }) + } + else { + createFinanceRecordApi({ + amount, + category_id: selectedCategory.id, + remark: financeRecord.remark, + record_time: financeRecord.record_time, + }) + .then((res) => { + if (res.isSuccess) { + router.back() + } + }) + } + } + + return ( + + + + { + router.navigate('/finance/category/setting') + }} + > + + 管理 + + )} + right={( + { + router.back() + }} + > + 取消 + + )} + routes={[ + { + key: 'expense', + title: '支出', + element: ( + { + setShowKeydown(true) + setSelectedCategory(data) + }} + className="mt-4" + /> + ), + }, + { + key: 'income', + title: '收入', + element: ( + { + setShowKeydown(true) + setSelectedCategory(data) + }} + className="mt-4" + /> + ), + }, + ]} + /> + + { + setFinanceRecord({ + ...financeRecord, + record_time: data.record_time, + amount: data.amount, + remark: data.remark, + }) + }} + onDone={handleSaveRecord} + /> + + + ) +} diff --git a/code/allapp/allapp-rn/src/app/space/sub-page/settings/index.tsx b/code/allapp/allapp-rn/src/app/space/sub-page/settings/index.tsx index db2e6267..1e978962 100644 --- a/code/allapp/allapp-rn/src/app/space/sub-page/settings/index.tsx +++ b/code/allapp/allapp-rn/src/app/space/sub-page/settings/index.tsx @@ -1,5 +1,5 @@ import type { UserSpace } from '@/types/space' -import { router, useFocusEffect, useLocalSearchParams, useNavigation } from 'expo-router' +import { router, useFocusEffect, useLocalSearchParams } from 'expo-router' import { useCallback, useEffect, useRef, useState } from 'react' import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native' import { KeyboardAvoidingView } from 'react-native-keyboard-controller' diff --git a/code/allapp/allapp-rn/src/components/ui/spinner/index.tsx b/code/allapp/allapp-rn/src/components/ui/spinner/index.tsx new file mode 100644 index 00000000..f5e88ed9 --- /dev/null +++ b/code/allapp/allapp-rn/src/components/ui/spinner/index.tsx @@ -0,0 +1,40 @@ +'use client'; +import { ActivityIndicator } from 'react-native'; +import React from 'react'; +import { tva } from '@gluestack-ui/utils/nativewind-utils'; +import { cssInterop } from 'nativewind'; + +cssInterop(ActivityIndicator, { + className: { target: 'style', nativeStyleToProp: { color: true } }, +}); + +const spinnerStyle = tva({}); + +const Spinner = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(function Spinner( + { + className, + color, + focusable = false, + 'aria-label': ariaLabel = 'loading', + ...props + }, + ref +) { + return ( + + ); +}); + +Spinner.displayName = 'Spinner'; + +export { Spinner }; diff --git a/code/allapp/allapp-rn/src/request/api/finance/category.ts b/code/allapp/allapp-rn/src/request/api/finance/category.ts new file mode 100644 index 00000000..76ce67be --- /dev/null +++ b/code/allapp/allapp-rn/src/request/api/finance/category.ts @@ -0,0 +1,24 @@ +import type { CreateFinanceCategoryParams, CreateFinanceCategoryResult, DeleteFinanceCategoryParams, ListFinanceCategoriesResult, UpdateFinanceCategoryParams, UpdateFinanceCategorySortParams } from '@/types/finance/category' +import alovaInstance from '../../index' + +const API_PREFIX = '/finance/category' + +export function listFinanceCategoriesApi() { + return alovaInstance.Post>(`${API_PREFIX}/list`) +} + +export function createFinanceCategoryApi(params: CreateFinanceCategoryParams) { + return alovaInstance.Post>(`${API_PREFIX}/create`, params) +} + +export function updateFinanceCategoryApi(params: UpdateFinanceCategoryParams) { + return alovaInstance.Post>(`${API_PREFIX}/update`, params) +} + +export function updateFinanceCategorySortNumberApi(params: UpdateFinanceCategorySortParams) { + return alovaInstance.Post>(`${API_PREFIX}/sortNumber`, params) +} + +export function deleteFinanceCategoryApi(params: DeleteFinanceCategoryParams) { + return alovaInstance.Post>(`${API_PREFIX}/delete`, params) +} diff --git a/code/allapp/allapp-rn/src/request/api/finance/record.ts b/code/allapp/allapp-rn/src/request/api/finance/record.ts new file mode 100644 index 00000000..878663db --- /dev/null +++ b/code/allapp/allapp-rn/src/request/api/finance/record.ts @@ -0,0 +1,20 @@ +import type { CreateFinanceRecordParams, DeleteFinanceRecordParams, ListFinanceRecordsParams, ListFinanceRecordsResult, UpdateFinanceRecordParams } from '@/types/finance/record' +import alovaInstance from '../../index' + +const API_PREFIX = '/finance/record' + +export function listFinanceRecordsApi(params: ListFinanceRecordsParams) { + return alovaInstance.Post>(`${API_PREFIX}/list`, params) +} + +export function createFinanceRecordApi(params: CreateFinanceRecordParams) { + return alovaInstance.Post>(`${API_PREFIX}/create`, params) +} + +export function updateFinanceRecordApi(params: UpdateFinanceRecordParams) { + return alovaInstance.Post>(`${API_PREFIX}/update`, params) +} + +export function deleteFinanceRecordApi(params: DeleteFinanceRecordParams) { + return alovaInstance.Post>(`${API_PREFIX}/delete`, params) +} diff --git a/code/allapp/allapp-rn/src/request/index.ts b/code/allapp/allapp-rn/src/request/index.ts index e1b1517e..960c014f 100644 --- a/code/allapp/allapp-rn/src/request/index.ts +++ b/code/allapp/allapp-rn/src/request/index.ts @@ -31,8 +31,8 @@ function isWhiteListed(url: string) { } const alovaInstance = createAlova({ - baseURL: 'http://192.168.5.6:8086/app', - // baseURL: 'http://192.168.1.137:8086/app', + // baseURL: 'http://192.168.5.6:8086/app', + baseURL: 'http://192.168.1.137:8086/app', requestAdapter: axiosRequestAdapter(), timeout: 5000, cacheFor: null, diff --git a/code/allapp/allapp-rn/src/store/finance.ts b/code/allapp/allapp-rn/src/store/finance.ts new file mode 100644 index 00000000..c4dc0ce1 --- /dev/null +++ b/code/allapp/allapp-rn/src/store/finance.ts @@ -0,0 +1,373 @@ +import type { FinanceCategory, IconGroupType } from '@/types/finance/category' +import type { FinanceRecord, FinanceRecordGroup } from '@/types/finance/record' +import { create } from 'zustand' +import { listFinanceCategoriesApi } from '@/request/api/finance/category' +import { FinanceCategoryEnum } from '@/types/finance/category' +import { add, sub } from '@/utils/math' + +interface FinanceState { + // 分类 + DefaultGroupCategories: IconGroupType[] + categories: FinanceCategory[] + setCategories: (categories: FinanceCategory[]) => void + ensureCategoriesLoaded: () => Promise + getExpenseCategories: () => FinanceCategory[] + getIncomeCategories: () => FinanceCategory[] + createCategory: (category: FinanceCategory) => void + updateCategory: (category: FinanceCategory) => void + deleteCategory: (id: number) => void + updateCategorySortNumber: (data: FinanceCategory[], type: number) => void + isDuplicateCategory: (name: string, type: number, excludeId: number | null) => boolean + getDefaultGroupRow: (iconName: string) => number + getCategoryById: (id: number) => FinanceCategory | null + getCategoryNextSortNumber: (type: number) => number + + // 记录 + records: FinanceRecordGroup[] + getTotalIncome: () => string + getTotalExpense: () => string + getTotalBalance: () => string + setRecords: (records: FinanceRecord[]) => void + getRecordById: (id: number) => FinanceRecord | null +} + +function recomputeGroups(records: FinanceRecord[]) { + const { getCategoryById } = useFinanceStore.getState() + + const groupsMap: Record = {} + + records.forEach((r) => { + const category = getCategoryById(r.category_id) + if (!category) + return + + const group = groupsMap[r.record_time] ?? { + record_time: r.record_time, + records: [], + income: '0.00', + expense: '0.00', + } + + group.records.push(r) + + if (category.type === FinanceCategoryEnum.Expense) + group.expense = add(group.expense, r.amount) + + if (category.type === FinanceCategoryEnum.Income) + group.income = add(group.income, r.amount) + + groupsMap[r.record_time] = group + }) + + return Object.values(groupsMap).sort((a, b) => + b.record_time.localeCompare(a.record_time), + ) +} + +export const useFinanceStore = create()( + (set, get) => ({ + // ================= 分类 ================= + DefaultGroupCategories: [ + { + index: 0, + type: 'title', + value: '餐饮', + }, + { + index: 1, + type: 'icons', + value: [ + 'money_canyin', + 'money_yinpin', + 'money_lingshi', + 'money_xiaoye', + ], + }, + { + index: 2, + type: 'title', + value: '娱乐', + }, + { + index: 3, + type: 'icons', + value: [ + 'money_yule', + 'money_youxi', + 'money_dianying', + ], + }, + { + index: 4, + type: 'title', + value: '交通', + }, + { + index: 5, + type: 'icons', + value: [ + 'money_jiaotong', + 'money_tingchefei', + 'money_jiayou', + ], + }, + { + index: 6, + type: 'title', + value: '生活消费', + }, + { + index: 7, + type: 'icons', + value: [ + 'money_lijin', + 'money_jiadian', + 'money_riyongpin', + 'money_shucai', + 'money_meifa', + ], + }, + { + index: 8, + type: 'icons', + value: [ + 'money_shuidian', + 'money_wenju', + 'money_zhufang', + 'money_baoxian', + 'money_baojianpin', + ], + }, + { + index: 9, + type: 'icons', + value: [ + 'money_huafei', + 'money_lvyou', + 'money_gouwu', + 'money_shuben', + 'money_chongwu', + ], + }, + { + index: 10, + type: 'icons', + value: [ + 'money_ruanjian', + ], + }, + { + index: 11, + type: 'title', + value: '健康', + }, + { + index: 12, + type: 'icons', + value: [ + 'money_tijian', + 'money_yao', + 'money_jiuyi', + ], + }, + { + index: 13, + type: 'title', + value: '公益', + }, + { + index: 14, + type: 'icons', + value: [ + 'money_gongyi', + ], + }, + { + index: 15, + type: 'title', + value: '兼职与收入', + }, + { + index: 16, + type: 'icons', + value: [ + 'money_gongzi', + 'money_jianzhi', + ], + }, + { + index: 17, + type: 'title', + value: '鲜花与水果', + }, + { + index: 18, + type: 'icons', + value: [ + 'money_shuiguo', + 'money_xianhua', + ], + }, + { + index: 19, + type: 'title', + value: '转账与转移', + }, + { + index: 20, + type: 'icons', + value: [ + 'money_zhuanzhang', + ], + }, + { + index: 21, + type: 'title', + value: '家庭与家电', + }, + { + index: 22, + type: 'icons', + value: [ + 'money_kuaidi', + 'money_weixiu', + 'money_jiaju', + 'money_muying', + 'money_liwu', + ], + }, + { + index: 23, + type: 'title', + value: '运动与娱乐', + }, + { + index: 24, + type: 'icons', + value: [ + 'money_yundong', + 'money_peixun', + ], + }, + ], + + categories: [], + + setCategories: categories => + set({ + categories: [...categories].sort( + (a, b) => (a.sort_number ?? 0) - (b.sort_number ?? 0), + ), + }), + + ensureCategoriesLoaded: async () => { + if (get().categories.length > 0) + return + const res = await listFinanceCategoriesApi() + if (res.isSuccess) { + set({ categories: res.data.categories }) + } + }, + + getExpenseCategories: () => + get().categories.filter(c => c.type === FinanceCategoryEnum.Expense), + + getIncomeCategories: () => + get().categories.filter(c => c.type === FinanceCategoryEnum.Income), + + getCategoryById: (id: number) => { + return get().categories.find(c => c.id === id) || null + }, + + createCategory: (category) => { + set({ + categories: [...get().categories, category], + }) + }, + updateCategory: (category) => { + set(state => ({ + categories: state.categories.map(c => + c.id !== category.id + ? c + : { + ...c, + ...category, + }, + ), + })) + }, + deleteCategory: (id) => { + const allCategories = get().categories + const finalCategories = allCategories.filter(c => c.id !== id) + set({ categories: finalCategories }) + }, + updateCategorySortNumber: (data, type) => { + const allCategories = get().categories + const otherCategories = allCategories.filter(c => c.type !== type) + const updatedData = data.map((item, index) => ({ + ...item, + sort_number: index, + })) + + set({ categories: [...otherCategories, ...updatedData] }) + }, + isDuplicateCategory: (name, type, excludeId) => { + const categories = get().categories || [] + return categories.some(c => + c.name === name + && c.type === type + && c.id !== excludeId, + ) + }, + getDefaultGroupRow: (iconName) => { + const iconGroups = get().DefaultGroupCategories + + for (const group of iconGroups) { + if (group.type === 'icons' && group.value.includes(iconName)) { + return group.index + } + } + + return 0 + }, + getCategoryNextSortNumber: (type: number): number => { + const categories = get().categories.filter(c => c.type === type) + + if (categories.length === 0) { + return 0 + } + + const maxSortNumber = Math.max(...categories.map(c => c.sort_number)) + + return maxSortNumber + 1 + }, + // ================= 分类 ================= + + // ================= 记录 ================= + records: [], + getTotalIncome: () => { + return get().records.reduce((sum, g) => add(sum, g.income), '0.00') + }, + + getTotalExpense: () => { + return get().records.reduce((sum, g) => add(sum, g.expense), '0.00') + }, + + getTotalBalance: () => { + return sub(get().getTotalIncome(), get().getTotalExpense()) + }, + setRecords: (records) => { + const newGroups = recomputeGroups(records) + set({ records: newGroups }) + }, + getRecordById: (id) => { + for (const group of get().records) { + const record = group.records.find(r => r.id === id) + if (record) + return record + } + return null + }, + // ================= 记录 ================= + }), +) diff --git a/code/allapp/allapp-rn/src/store/navigation.ts b/code/allapp/allapp-rn/src/store/navigation.ts index 97486647..eda481fc 100644 --- a/code/allapp/allapp-rn/src/store/navigation.ts +++ b/code/allapp/allapp-rn/src/store/navigation.ts @@ -19,7 +19,7 @@ interface NavState { } const DEFAULT_NAV_MENUS: NavItem[] = [ - { label: '记账', path: '/money', icon: 'CircleDollarSign' }, + { label: '记账', path: '/finance', icon: 'CircleDollarSign' }, { label: '恋爱记', path: '/love', icon: 'Heart' }, { label: '聊天', path: '/chat', icon: 'MessageCircle' }, { label: '个人中心', path: '/user', icon: 'CircleUserRound' }, diff --git a/code/allapp/allapp-rn/src/types/finance/category.ts b/code/allapp/allapp-rn/src/types/finance/category.ts new file mode 100644 index 00000000..a8f1be08 --- /dev/null +++ b/code/allapp/allapp-rn/src/types/finance/category.ts @@ -0,0 +1,69 @@ +export interface FinanceCategory { + id: number + name: string + icon: string + type: number + sort_number: number +} + +export enum FinanceCategoryEnum { + Expense = 0, // 支出 + Income = 1, // 收入 +} + +// 记账分类 +export interface ListFinanceCategoriesResult { + categories: FinanceCategory[] +} + +// 创建记账分类 +export interface CreateFinanceCategoryParams { + name: string + icon: string + type: number + sort_number: number +} +export interface CreateFinanceCategoryResult { + id: number +} + +// 更新记账分类排序 +export interface UpdateFinanceCategorySortItem { + id: number + sort_number: number +} +export interface UpdateFinanceCategorySortParams { + categories: UpdateFinanceCategorySortItem[] +} + +// 删除记账分类 +export interface DeleteFinanceCategoryParams { + id: number +} + +// 更新记账分类 +export interface UpdateFinanceCategoryParams { + id: number + name: string + icon: string +} + +// 图标分组基础接口 +interface BaseIconGroupType { + index: number +} + +// 当 type 为 'title' 时,value 必须是 string +interface TitleIconGroupType extends BaseIconGroupType { + type: 'title' + value: string +} + +// 当 type 为 'icons' 时,value 必须是 string[] +interface IconsIconGroupType extends BaseIconGroupType { + type: 'icons' + value: string[] +} + +// 联合类型 +export type IconGroupType = TitleIconGroupType | IconsIconGroupType diff --git a/code/allapp/allapp-rn/src/types/finance/record.ts b/code/allapp/allapp-rn/src/types/finance/record.ts new file mode 100644 index 00000000..2465c38f --- /dev/null +++ b/code/allapp/allapp-rn/src/types/finance/record.ts @@ -0,0 +1,45 @@ +export interface FinanceRecordGroup { + record_time: string + expense: string + income: string + records: FinanceRecord[] +} + +export interface FinanceRecord { + id: number + category_id: number + amount: string + remark: string + record_time: string + member_id: number + member_name: string +} + +export interface ListFinanceRecordsParams { + start_time: string + end_time: string + category_id: number +} + +export interface ListFinanceRecordsResult { + records: FinanceRecord[] +} + +export interface CreateFinanceRecordParams { + category_id: number + amount: string + record_time: string + remark: string +} + +export interface UpdateFinanceRecordParams { + id: number + category_id: number + amount: string + record_time: string + remark: string +} + +export interface DeleteFinanceRecordParams { + id: number +}