u
This commit is contained in:
1 parent
46106d0c76
commit
34240d2e16
36 files changed
+2853
-165
No files matched your search
@@ -4,6 +4,7 @@
|
||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/4db2dbd4-857e-4234-ba5e-f8cbf0ba6e5d/console_1.sql" value="4db2dbd4-857e-4234-ba5e-f8cbf0ba6e5d" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/finance_category_default.sql" value="4db2dbd4-857e-4234-ba5e-f8cbf0ba6e5d" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/finance_category_space.sql" value="4db2dbd4-857e-4234-ba5e-f8cbf0ba6e5d" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/finance_record.sql" value="4db2dbd4-857e-4234-ba5e-f8cbf0ba6e5d" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/space.sql" value="4db2dbd4-857e-4234-ba5e-f8cbf0ba6e5d" />
|
||||
</component>
|
||||
</project>
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/db/queries/finance_category_default.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/finance_category_space.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/finance_record.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/space.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/space_member.sql" dialect="GenericSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/queries/user.sql" dialect="GenericSQL" />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, "更新成功")
|
||||
}
|
||||
-115
@@ -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 {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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);
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<View className="flex-1 bg-white">
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Slot />
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useNavigation } from 'expo-router'
|
||||
import React, { useState } from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import { AButton, AModal } from '@/components/common/ao'
|
||||
@@ -7,6 +8,9 @@ import { useSpaceStore } from '@/store/space'
|
||||
export default function TestScreen() {
|
||||
const [showDissolve, setShowDissolve] = useState(false)
|
||||
const spaceStore = useSpaceStore()
|
||||
const navigation = useNavigation()
|
||||
|
||||
console.log(navigation.getState()?.routes)
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
|
||||
+8
-8
@@ -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 (
|
||||
+6
-7
@@ -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}
|
||||
</Text>
|
||||
{
|
||||
item.user_id !== userID && (
|
||||
item.member_id !== userID && (
|
||||
<Text className="text-gray-500 text-xs italic">
|
||||
{item.username}
|
||||
{item.member_name}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -186,7 +185,7 @@ export default function StaticTransactions({
|
||||
}}
|
||||
>
|
||||
{
|
||||
userID === item.user_id && (
|
||||
userID === item.member_id && (
|
||||
<>
|
||||
<MenuItem
|
||||
key="update"
|
||||
+26
-23
@@ -1,24 +1,25 @@
|
||||
import type { DateMorePickerMode } from '@/components/common/ao/DateMorePicker'
|
||||
import { useFocusEffect, useRouter } from 'expo-router'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Image, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { ADateMorePicker, ADatePicker, ADrawer, AListItem, LucideIcon } from '@/components/common/ao'
|
||||
import { resolveDateRange } from '@/components/common/ao/DateMorePicker'
|
||||
import { Fab, FabIcon } from '@/components/ui/fab'
|
||||
import { AddIcon } from '@/components/ui/icon'
|
||||
import { listFinanceRecordsApi } from '@/request/api/finance/record'
|
||||
import { deleteMoneyRecordApi, listMoneyRecordsApi } from '@/request/api/money/record'
|
||||
import { useMoneyCategoryStore } from '@/store/moneyCategory'
|
||||
import { useMoneyRecordStore } from '@/store/moneyRecord'
|
||||
import { useFinanceStore } from '@/store/finance'
|
||||
import { getCurrentYearMonth, normalizeDateTime } from '@/utils/time'
|
||||
import { toast } from '@/utils/toast'
|
||||
import CategorySheet from './components/CategorySheet'
|
||||
import TransactionsList from './components/TransactionsList'
|
||||
|
||||
export default function Money() {
|
||||
const moneyRecordStore = useMoneyRecordStore()
|
||||
const moneyCategoryStore = useMoneyCategoryStore()
|
||||
const router = useRouter()
|
||||
|
||||
const financeStore = useFinanceStore()
|
||||
|
||||
// 安全高度
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
@@ -37,17 +38,16 @@ export default function Money() {
|
||||
// 操作菜单
|
||||
const [showActionsMenu, setShowActionsMenu] = useState(false)
|
||||
|
||||
function fetchMoneyRecords() {
|
||||
console.log(searchParams)
|
||||
|
||||
function fetchFinanceRecords() {
|
||||
const resolveDate = resolveDateRange(dateMode, searchParams.startTime)
|
||||
|
||||
listMoneyRecordsApi({
|
||||
listFinanceRecordsApi({
|
||||
start_time: resolveDate.startTime,
|
||||
end_time: resolveDate.endTime,
|
||||
category_id: 0,
|
||||
}).then((res) => {
|
||||
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()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -158,7 +160,7 @@ export default function Money() {
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{moneyRecordStore.getTotalExpense()}
|
||||
{financeStore.getTotalExpense()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -169,7 +171,7 @@ export default function Money() {
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{moneyRecordStore.getTotalBalance()}
|
||||
{financeStore.getTotalBalance()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -178,16 +180,16 @@ export default function Money() {
|
||||
|
||||
<View className="flex-1 mt-2">
|
||||
<TransactionsList
|
||||
records={moneyRecordStore.records}
|
||||
records={financeStore.records}
|
||||
onUpdate={(data) => {
|
||||
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()
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -203,7 +205,7 @@ export default function Money() {
|
||||
<Fab
|
||||
size="lg"
|
||||
onPress={() => {
|
||||
router.navigate('/money/record/editor')
|
||||
router.navigate('/finance/record/editor')
|
||||
}}
|
||||
>
|
||||
<FabIcon as={AddIcon} size="xl" />
|
||||
@@ -247,7 +249,7 @@ export default function Money() {
|
||||
<CategorySheet
|
||||
open={showCategorySheet}
|
||||
value={searchParams.category}
|
||||
moneyCategories={moneyCategoryStore.categories}
|
||||
moneyCategories={financeStore.categories}
|
||||
onClose={() => {
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
@@ -26,21 +26,21 @@ export default function Index() {
|
||||
|
||||
{/* <AButton
|
||||
className="w-full"
|
||||
onPress={() => ('/money')}
|
||||
onPress={() => ('/finance')}
|
||||
>
|
||||
开始使用
|
||||
</AButton> */}
|
||||
<View className="flex flex-row gap-3 mt-4">
|
||||
<AButton
|
||||
className="flex-1"
|
||||
onPress={() => ('/money')}
|
||||
onPress={() => ('/finance')}
|
||||
>
|
||||
创建空间
|
||||
</AButton>
|
||||
<AButton
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
onPress={() => ('/money')}
|
||||
onPress={() => ('/finance')}
|
||||
>
|
||||
加入空间
|
||||
</AButton>
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export default function EditCategory({
|
||||
open,
|
||||
onClose,
|
||||
type,
|
||||
onConfirm,
|
||||
category,
|
||||
}: EditCategoryProps) {
|
||||
const financeStore = useFinanceStore()
|
||||
|
||||
const [isOpen, setIsOpen] = useState(open)
|
||||
const [form, setForm] = useState<EditCategoryForm>({ icon: '', name: '' })
|
||||
const [confirmDisabled, setConfirmDisabled] = useState(true)
|
||||
const [confirmLoading, setConfirmLoading] = useState(false)
|
||||
const listRef = useRef<LegendListRef>(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 (
|
||||
<Actionsheet
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<ActionsheetBackdrop />
|
||||
<ActionsheetContent style={{
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<ActionsheetDragIndicatorWrapper>
|
||||
<ActionsheetDragIndicator />
|
||||
</ActionsheetDragIndicatorWrapper>
|
||||
<View>
|
||||
<View className="flex flex-row items-center justify-between w-full p-4">
|
||||
<AButton size="xs" variant="outline" onPress={handleClose}>
|
||||
取消
|
||||
</AButton>
|
||||
<Text className="text-lg font-medium">
|
||||
{type === FinanceCategoryEnum.Expense ? '添加支出分类' : '添加收入分类'}
|
||||
</Text>
|
||||
<AButton
|
||||
size="xs"
|
||||
disabled={confirmDisabled}
|
||||
loading={confirmLoading}
|
||||
onPress={async () => {
|
||||
if (!onConfirm)
|
||||
return
|
||||
try {
|
||||
setConfirmLoading(true)
|
||||
await onConfirm({ ...form, name: form.name.trim() })
|
||||
}
|
||||
finally {
|
||||
setConfirmLoading(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</AButton>
|
||||
</View>
|
||||
<View>
|
||||
<AInput
|
||||
className="mx-4 mb-4"
|
||||
placeholder="分类名称"
|
||||
value={form.name}
|
||||
onChangeText={text => setForm(prevForm => ({ ...prevForm, name: text }))}
|
||||
/>
|
||||
<LegendList
|
||||
ref={listRef}
|
||||
data={financeStore.DefaultGroupCategories}
|
||||
extraData={form.icon}
|
||||
keyExtractor={item => String(item.index)}
|
||||
recycleItems
|
||||
initialContainerPoolRatio={20}
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
}}
|
||||
initialScrollIndex={financeStore.getDefaultGroupRow(category?.icon || '')}
|
||||
renderItem={({ item }) => (
|
||||
<View className="mb-1" key={item.index}>
|
||||
{
|
||||
item.type === 'title'
|
||||
? (
|
||||
<Text className="text-center text-sm mb-3">{item.value}</Text>
|
||||
)
|
||||
: (
|
||||
<View className="flex flex-row flex-wrap">
|
||||
{item.value.map((icon) => {
|
||||
const selected = form.icon === icon
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={icon}
|
||||
className="w-1/5 mb-3 flex items-center"
|
||||
onPress={() => {
|
||||
setForm(prevForm => ({
|
||||
...prevForm,
|
||||
icon,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: selected ? '#333' : '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<MoneyCategoryIcon
|
||||
name={icon}
|
||||
size={32}
|
||||
color={selected ? '#fff' : '#000'}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ActionsheetContent>
|
||||
</Actionsheet>
|
||||
)
|
||||
}
|
||||
@@ -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<Animated.ScrollView>()
|
||||
|
||||
const [categories, setCategories] = useState<FinanceCategory[]>([])
|
||||
|
||||
const [draggingId, setDraggingId] = useState<string>('')
|
||||
const [draggingDeleteZone, setDraggingDeleteZone] = useState<boolean>(false)
|
||||
const [deleteTimer, setDeleteTimer] = useState<number | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
const [showEditCategory, setshowEditCategory] = useState<boolean>(false)
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<FinanceCategory | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setCategories(selectedTab === FinanceCategoryEnum.Expense ? financeStore.getExpenseCategories() : financeStore.getIncomeCategories())
|
||||
}, [selectedTab, financeStore.categories])
|
||||
|
||||
const renderItem = useCallback<SortableGridRenderItem<FinanceCategory>>(
|
||||
({ item }) => (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setSelectedCategory(item)
|
||||
setshowEditCategory(true)
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<MoneyCategoryIcon size={28} name={item.icon} />
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
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 (
|
||||
<View className="flex-1">
|
||||
<CustomHeader
|
||||
title="记账类别"
|
||||
/>
|
||||
|
||||
<View className="flex-1">
|
||||
<View className="pt-2 mb-4 px-3 flex flex-row items-center justify-between">
|
||||
<ATab
|
||||
defaultKey={selectedTab}
|
||||
onChange={(key) => {
|
||||
setSelectedTab(key as FinanceCategoryEnum)
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: FinanceCategoryEnum.Expense,
|
||||
label: '支出',
|
||||
},
|
||||
{
|
||||
key: FinanceCategoryEnum.Income,
|
||||
label: '收入',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AButton
|
||||
className="rounded-full"
|
||||
size="sm"
|
||||
leftIcon={<LucideIcon icon="Plus" color="#fff" />}
|
||||
onPress={(() =>
|
||||
setshowEditCategory(true)
|
||||
)}
|
||||
>
|
||||
新增
|
||||
</AButton>
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Sortable.MultiZoneProvider>
|
||||
<Sortable.PortalProvider enabled>
|
||||
<Animated.ScrollView
|
||||
ref={scrollableRef}
|
||||
>
|
||||
<Sortable.Grid
|
||||
columnGap={10}
|
||||
rowGap={10}
|
||||
columns={4}
|
||||
data={categories}
|
||||
renderItem={renderItem}
|
||||
scrollableRef={scrollableRef}
|
||||
autoScrollActivationOffset={200}
|
||||
showDropIndicator
|
||||
onDragStart={(data) => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<Sortable.BaseZone
|
||||
onItemEnter={() => {
|
||||
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}
|
||||
>
|
||||
<View
|
||||
className={`flex flex-row items-center justify-center h-14 gap-2 ${
|
||||
draggingDeleteZone ? 'bg-red-600' : 'bg-red-400'
|
||||
}`}
|
||||
>
|
||||
{
|
||||
deleteLoading
|
||||
? (
|
||||
<Spinner color="white" />
|
||||
)
|
||||
: (
|
||||
<LucideIcon icon="Trash" size={18} color="#fff" />
|
||||
)
|
||||
}
|
||||
<Text className="text-base text-white ">
|
||||
{draggingDeleteZone ? '松手删除(将删除该类下所有记录)' : '拖到这里删除'}
|
||||
</Text>
|
||||
</View>
|
||||
</Sortable.BaseZone>
|
||||
</Sortable.PortalProvider>
|
||||
</Sortable.MultiZoneProvider>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<EditCategory
|
||||
type={selectedTab}
|
||||
open={showEditCategory}
|
||||
onClose={() => {
|
||||
setshowEditCategory(false)
|
||||
setSelectedCategory(null)
|
||||
}}
|
||||
category={selectedCategory}
|
||||
onConfirm={handleConfirmEdit}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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: <Text className="text-2xl">1</Text> },
|
||||
{ key: '2', content: <Text className="text-2xl">2</Text> },
|
||||
{ key: '3', content: <Text className="text-2xl">3</Text> },
|
||||
{ key: 'del', content: <LucideIcon icon="Delete" size={22} strokeWidth={1.5} clickable={false} /> },
|
||||
{ key: '4', content: <Text className="text-2xl">4</Text> },
|
||||
{ key: '5', content: <Text className="text-2xl">5</Text> },
|
||||
{ key: '6', content: <Text className="text-2xl">6</Text> },
|
||||
{ key: '+', content: <LucideIcon icon="Plus" size={22} strokeWidth={1.5} clickable={false} /> },
|
||||
{ key: '7', content: <Text className="text-2xl">7</Text> },
|
||||
{ key: '8', content: <Text className="text-2xl">8</Text> },
|
||||
{ key: '9', content: <Text className="text-2xl">9</Text> },
|
||||
{ key: '-', content: <LucideIcon icon="Minus" size={22} strokeWidth={1.5} clickable={false} /> },
|
||||
{ key: 'clear', content: (
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Text className="text-xl text-gray-600">重置</Text>
|
||||
</View>
|
||||
) },
|
||||
{ key: '0', content: <Text className="text-2xl">0</Text> },
|
||||
{ key: '.', content: <LucideIcon icon="Dot" size={22} strokeWidth={1.5} clickable={false} /> },
|
||||
{ key: '/', content: (
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Text className="text-2xl">完成</Text>
|
||||
</View>
|
||||
) },
|
||||
]
|
||||
|
||||
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<typeof internalData>) => {
|
||||
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 (
|
||||
<View style={{
|
||||
backgroundColor: '#fff',
|
||||
elevation: 20,
|
||||
}}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<View className="h-28 flex pl-4 pr-4 justify-center">
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Pressable
|
||||
className="flex flex-row items-center gap-1.5 px-2 py-1.5 rounded-md bg-gray-50 active:opacity-70"
|
||||
onPress={() => setShowDatePicker(true)}
|
||||
>
|
||||
<LucideIcon icon="Calendar" size={16} color="#60a5fa" clickable={false} />
|
||||
<Text className="text-gray-600 text-sm">{internalData.record_time}</Text>
|
||||
<LucideIcon icon="ChevronDown" size={12} color="#aaa" clickable={false} />
|
||||
</Pressable>
|
||||
<Text className="text-3xl text-blue-700 text-right">{internalData.amount}</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-gray-400 text-right">{keydownInput}</Text>
|
||||
<AInput
|
||||
value={internalData.remark}
|
||||
placeholder="备注"
|
||||
className="bg-[#f2f3f5] border-0"
|
||||
size="sm"
|
||||
onChangeText={val => handleChange({ remark: val })}
|
||||
onFocus={() => setRemarkIsFocused(true)}
|
||||
onBlur={() => setRemarkIsFocused(false)}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
<View className="flex flex-row flex-wrap" style={{ height: 220 }}>
|
||||
{keys.map((item, idx) => {
|
||||
const isFirstCol = idx % 4 === 0
|
||||
const isFirstRow = idx < 4
|
||||
const isLastCol = (idx + 1) % 4 === 0
|
||||
const isLastRow = idx >= 12
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
onPress={() => 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 (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: pressed ? pressedBg : defaultBg,
|
||||
}}
|
||||
/>
|
||||
{item.content}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
{remarkIsFocused && keyboardHeight > 220 && (
|
||||
<View style={{ height: keyboardHeight + 10 - 220 }} />
|
||||
)}
|
||||
|
||||
<ADatePicker
|
||||
pickerTitle="记账时间"
|
||||
mode="date"
|
||||
date={internalData.record_time}
|
||||
show={showDatePicker}
|
||||
onClose={() => {
|
||||
setShowDatePicker(false)
|
||||
}}
|
||||
onConfirm={(date) => {
|
||||
setShowDatePicker(false)
|
||||
handleChange({ record_time: date })
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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<FlatList<any>>(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 (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={data}
|
||||
numColumns={NUM_COLUMNS}
|
||||
keyExtractor={item => 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 (
|
||||
<Pressable
|
||||
style={{
|
||||
width: `${100 / NUM_COLUMNS}%`,
|
||||
height: 80,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onPress={() => onSelectCategory?.(item)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 28,
|
||||
marginTop: 4,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: isSelected ? '#333' : '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<MoneyCategoryIcon
|
||||
name={item.icon}
|
||||
size={28}
|
||||
color={isSelected ? '#fff' : '#000'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={{ marginTop: 4, fontSize: 14, textAlign: 'center' }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<FinanceCategory | null>(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<FinanceRecord>(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 (
|
||||
<View className="flex-1">
|
||||
<CustomHeader
|
||||
showHeader={false}
|
||||
screenProps={{
|
||||
options: {
|
||||
animation: 'slide_from_bottom',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<PagerTabs
|
||||
selectedIndex={selectedCategory?.type}
|
||||
left={(
|
||||
<TouchableOpacity
|
||||
className="w-[30%] flex items-center px-4 flex-row"
|
||||
onPress={() => {
|
||||
router.navigate('/finance/category/setting')
|
||||
}}
|
||||
>
|
||||
<LucideIcon icon="LayoutList" />
|
||||
<Text className="ml-1">管理</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
right={(
|
||||
<TouchableOpacity
|
||||
className="w-[30%] flex flex-row items-center px-4"
|
||||
onPress={() => {
|
||||
router.back()
|
||||
}}
|
||||
>
|
||||
<Text className="ml-auto text-base text-gray-400">取消</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
routes={[
|
||||
{
|
||||
key: 'expense',
|
||||
title: '支出',
|
||||
element: (
|
||||
<CategoryGrid
|
||||
data={expense}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={(data) => {
|
||||
setShowKeydown(true)
|
||||
setSelectedCategory(data)
|
||||
}}
|
||||
className="mt-4"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'income',
|
||||
title: '收入',
|
||||
element: (
|
||||
<CategoryGrid
|
||||
data={income}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={(data) => {
|
||||
setShowKeydown(true)
|
||||
setSelectedCategory(data)
|
||||
}}
|
||||
className="mt-4"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AmountKeyboard
|
||||
show={showKeydown}
|
||||
record_time={financeRecord.record_time}
|
||||
amount={financeRecord.amount}
|
||||
remark={financeRecord.remark}
|
||||
onChange={(data) => {
|
||||
setFinanceRecord({
|
||||
...financeRecord,
|
||||
record_time: data.record_time,
|
||||
amount: data.amount,
|
||||
remark: data.remark,
|
||||
})
|
||||
}}
|
||||
onDone={handleSaveRecord}
|
||||
/>
|
||||
</View>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<typeof ActivityIndicator>,
|
||||
React.ComponentProps<typeof ActivityIndicator>
|
||||
>(function Spinner(
|
||||
{
|
||||
className,
|
||||
color,
|
||||
focusable = false,
|
||||
'aria-label': ariaLabel = 'loading',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
ref={ref}
|
||||
focusable={focusable}
|
||||
aria-label={ariaLabel}
|
||||
{...props}
|
||||
color={color}
|
||||
className={spinnerStyle({ class: className })}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Spinner.displayName = 'Spinner';
|
||||
|
||||
export { Spinner };
|
||||
@@ -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<ResponseData<ListFinanceCategoriesResult>>(`${API_PREFIX}/list`)
|
||||
}
|
||||
|
||||
export function createFinanceCategoryApi(params: CreateFinanceCategoryParams) {
|
||||
return alovaInstance.Post<ResponseData<CreateFinanceCategoryResult>>(`${API_PREFIX}/create`, params)
|
||||
}
|
||||
|
||||
export function updateFinanceCategoryApi(params: UpdateFinanceCategoryParams) {
|
||||
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/update`, params)
|
||||
}
|
||||
|
||||
export function updateFinanceCategorySortNumberApi(params: UpdateFinanceCategorySortParams) {
|
||||
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/sortNumber`, params)
|
||||
}
|
||||
|
||||
export function deleteFinanceCategoryApi(params: DeleteFinanceCategoryParams) {
|
||||
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/delete`, params)
|
||||
}
|
||||
@@ -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<ResponseData<ListFinanceRecordsResult>>(`${API_PREFIX}/list`, params)
|
||||
}
|
||||
|
||||
export function createFinanceRecordApi(params: CreateFinanceRecordParams) {
|
||||
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/create`, params)
|
||||
}
|
||||
|
||||
export function updateFinanceRecordApi(params: UpdateFinanceRecordParams) {
|
||||
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/update`, params)
|
||||
}
|
||||
|
||||
export function deleteFinanceRecordApi(params: DeleteFinanceRecordParams) {
|
||||
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/delete`, params)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>
|
||||
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<string, FinanceRecordGroup> = {}
|
||||
|
||||
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<FinanceState>()(
|
||||
(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
|
||||
},
|
||||
// ================= 记录 =================
|
||||
}),
|
||||
)
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user