From 53bd27d0824b943bdcdbfa49879f9acaa4568fca Mon Sep 17 00:00:00 2001 From: erio Date: Thu, 12 Mar 2026 19:03:56 +0800 Subject: [PATCH] feat(groups): wider modal, table layout, unit tests, user details - Widen modal to 'wide' size for table layout - Separate columns: ID, username, email, notes, status, rate - Use standard Pagination component for consistent style - Add user_notes and user_status to backend GroupRateMultiplierEntry - Add unit tests for GetGroupRateMultipliers and ClearGroupRateMultipliers - Allow duplicate user add (overwrites existing rate) --- .../repository/user_group_rate_repo.go | 4 +- .../service/admin_service_group_rate_test.go | 130 +++++++++++++++++ backend/internal/service/user_group_rate.go | 2 + frontend/src/api/admin/groups.ts | 2 + .../admin/group/GroupRateMultipliersModal.vue | 138 ++++++++++-------- frontend/src/i18n/locales/en.ts | 6 +- frontend/src/i18n/locales/zh.ts | 6 +- 7 files changed, 222 insertions(+), 66 deletions(-) create mode 100644 backend/internal/service/admin_service_group_rate_test.go diff --git a/backend/internal/repository/user_group_rate_repo.go b/backend/internal/repository/user_group_rate_repo.go index b0e3ae8f90..b8b6ca2132 100644 --- a/backend/internal/repository/user_group_rate_repo.go +++ b/backend/internal/repository/user_group_rate_repo.go @@ -98,7 +98,7 @@ func (r *userGroupRateRepository) GetByUserIDs(ctx context.Context, userIDs []in // GetByGroupID 获取指定分组下所有用户的专属倍率 func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int64) ([]service.UserGroupRateEntry, error) { query := ` - SELECT ugr.user_id, u.username, u.email, ugr.rate_multiplier + SELECT ugr.user_id, u.username, u.email, COALESCE(u.notes, ''), u.status, ugr.rate_multiplier FROM user_group_rate_multipliers ugr JOIN users u ON u.id = ugr.user_id WHERE ugr.group_id = $1 @@ -113,7 +113,7 @@ func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int6 var result []service.UserGroupRateEntry for rows.Next() { var entry service.UserGroupRateEntry - if err := rows.Scan(&entry.UserID, &entry.UserName, &entry.UserEmail, &entry.RateMultiplier); err != nil { + if err := rows.Scan(&entry.UserID, &entry.UserName, &entry.UserEmail, &entry.UserNotes, &entry.UserStatus, &entry.RateMultiplier); err != nil { return nil, err } result = append(result, entry) diff --git a/backend/internal/service/admin_service_group_rate_test.go b/backend/internal/service/admin_service_group_rate_test.go new file mode 100644 index 0000000000..f20016a904 --- /dev/null +++ b/backend/internal/service/admin_service_group_rate_test.go @@ -0,0 +1,130 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// userGroupRateRepoStubForGroupRate implements UserGroupRateRepository for group rate tests. +type userGroupRateRepoStubForGroupRate struct { + getByGroupIDData map[int64][]UserGroupRateEntry + getByGroupIDErr error + + deletedGroupIDs []int64 + deleteByGroupErr error +} + +func (s *userGroupRateRepoStubForGroupRate) GetByUserID(_ context.Context, _ int64) (map[int64]float64, error) { + panic("unexpected GetByUserID call") +} + +func (s *userGroupRateRepoStubForGroupRate) GetByUserAndGroup(_ context.Context, _, _ int64) (*float64, error) { + panic("unexpected GetByUserAndGroup call") +} + +func (s *userGroupRateRepoStubForGroupRate) GetByGroupID(_ context.Context, groupID int64) ([]UserGroupRateEntry, error) { + if s.getByGroupIDErr != nil { + return nil, s.getByGroupIDErr + } + return s.getByGroupIDData[groupID], nil +} + +func (s *userGroupRateRepoStubForGroupRate) SyncUserGroupRates(_ context.Context, _ int64, _ map[int64]*float64) error { + panic("unexpected SyncUserGroupRates call") +} + +func (s *userGroupRateRepoStubForGroupRate) DeleteByGroupID(_ context.Context, groupID int64) error { + s.deletedGroupIDs = append(s.deletedGroupIDs, groupID) + return s.deleteByGroupErr +} + +func (s *userGroupRateRepoStubForGroupRate) DeleteByUserID(_ context.Context, _ int64) error { + panic("unexpected DeleteByUserID call") +} + +func TestAdminService_GetGroupRateMultipliers(t *testing.T) { + t.Run("returns entries for group", func(t *testing.T) { + repo := &userGroupRateRepoStubForGroupRate{ + getByGroupIDData: map[int64][]UserGroupRateEntry{ + 10: { + {UserID: 1, UserName: "alice", UserEmail: "alice@test.com", RateMultiplier: 1.5}, + {UserID: 2, UserName: "bob", UserEmail: "bob@test.com", RateMultiplier: 0.8}, + }, + }, + } + svc := &adminServiceImpl{userGroupRateRepo: repo} + + entries, err := svc.GetGroupRateMultipliers(context.Background(), 10) + require.NoError(t, err) + require.Len(t, entries, 2) + require.Equal(t, int64(1), entries[0].UserID) + require.Equal(t, "alice", entries[0].UserName) + require.Equal(t, 1.5, entries[0].RateMultiplier) + require.Equal(t, int64(2), entries[1].UserID) + require.Equal(t, 0.8, entries[1].RateMultiplier) + }) + + t.Run("returns nil when repo is nil", func(t *testing.T) { + svc := &adminServiceImpl{userGroupRateRepo: nil} + + entries, err := svc.GetGroupRateMultipliers(context.Background(), 10) + require.NoError(t, err) + require.Nil(t, entries) + }) + + t.Run("returns empty slice for group with no entries", func(t *testing.T) { + repo := &userGroupRateRepoStubForGroupRate{ + getByGroupIDData: map[int64][]UserGroupRateEntry{}, + } + svc := &adminServiceImpl{userGroupRateRepo: repo} + + entries, err := svc.GetGroupRateMultipliers(context.Background(), 99) + require.NoError(t, err) + require.Nil(t, entries) + }) + + t.Run("propagates repo error", func(t *testing.T) { + repo := &userGroupRateRepoStubForGroupRate{ + getByGroupIDErr: errors.New("db error"), + } + svc := &adminServiceImpl{userGroupRateRepo: repo} + + _, err := svc.GetGroupRateMultipliers(context.Background(), 10) + require.Error(t, err) + require.Contains(t, err.Error(), "db error") + }) +} + +func TestAdminService_ClearGroupRateMultipliers(t *testing.T) { + t.Run("deletes by group ID", func(t *testing.T) { + repo := &userGroupRateRepoStubForGroupRate{} + svc := &adminServiceImpl{userGroupRateRepo: repo} + + err := svc.ClearGroupRateMultipliers(context.Background(), 42) + require.NoError(t, err) + require.Equal(t, []int64{42}, repo.deletedGroupIDs) + }) + + t.Run("returns nil when repo is nil", func(t *testing.T) { + svc := &adminServiceImpl{userGroupRateRepo: nil} + + err := svc.ClearGroupRateMultipliers(context.Background(), 42) + require.NoError(t, err) + }) + + t.Run("propagates repo error", func(t *testing.T) { + repo := &userGroupRateRepoStubForGroupRate{ + deleteByGroupErr: errors.New("delete failed"), + } + svc := &adminServiceImpl{userGroupRateRepo: repo} + + err := svc.ClearGroupRateMultipliers(context.Background(), 42) + require.Error(t, err) + require.Contains(t, err.Error(), "delete failed") + }) +} diff --git a/backend/internal/service/user_group_rate.go b/backend/internal/service/user_group_rate.go index 9d401ec870..c6c55957c6 100644 --- a/backend/internal/service/user_group_rate.go +++ b/backend/internal/service/user_group_rate.go @@ -7,6 +7,8 @@ type UserGroupRateEntry struct { UserID int64 `json:"user_id"` UserName string `json:"user_name"` UserEmail string `json:"user_email"` + UserNotes string `json:"user_notes"` + UserStatus string `json:"user_status"` RateMultiplier float64 `json:"rate_multiplier"` } diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index ff3c0c5011..b2d089d941 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -160,6 +160,8 @@ export interface GroupRateMultiplierEntry { user_id: number user_name: string user_email: string + user_notes: string + user_status: string rate_multiplier: number } diff --git a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue index 8ece0f3085..a42cc55425 100644 --- a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue +++ b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue @@ -1,5 +1,5 @@