This commit is contained in:
oneao committed 2026-03-04 17:30:05 +08:00
1 parent 46106d0c76
commit 34240d2e16
36 files changed
+2853 -165

No files matched your search

+1
View File
@@ -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
View File
@@ -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, "更新成功")
}
@@ -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;
+53
View File
@@ -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
}
+13
View File
@@ -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
+17
View File
@@ -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' }}>
@@ -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 (
@@ -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"
@@ -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)
}
+2 -2
View File
@@ -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,
+373
View File
@@ -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
}