From d39f45621b3b7d7c4aabfb137da33cb22389b937 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 19:09:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(adminuser):=20=E4=BC=98=E5=8C=96=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E7=AD=9B=E9=80=89=E5=B9=B6=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E7=AD=9B=E9=80=89=E4=BA=BA=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 免押额度与号主类型改为下拉筛选,支持有/无和优质/普通 - 新增用户成长等级筛选 - 列表上方固定展示筛选结果人数,补充组合筛选测试 --- backend/internal/modules/adminuser/dto.go | 6 + backend/internal/modules/adminuser/handler.go | 13 ++ .../internal/modules/adminuser/repository.go | 55 +++++--- .../modules/adminuser/repository_test.go | 122 ++++++++++++++++++ frontend/src/features/admin/api/adminUsers.ts | 13 +- .../features/admin/views/AdminUsersView.css | 20 +++ .../features/admin/views/AdminUsersView.vue | 59 +++++++-- 7 files changed, 259 insertions(+), 29 deletions(-) diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index a9b1633a..fdbaee3d 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -78,6 +78,12 @@ type ListQuery struct { Status string HasDepositFreeQuota bool QualityOwnerOnly bool + // DepositFreeQuota: has=有免押额度, none=无免押额度, 空=全部。 + DepositFreeQuota string + // OwnerType: quality=优质号主, normal=普通用户, 空=全部。 + OwnerType string + // GrowthLevel: 成长等级 code(normal/platinum/diamond/peak),空=全部。 + GrowthLevel string } type PaginatedResult struct { diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index 2ca3e669..932b4c94 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -42,6 +42,9 @@ func (h *Handler) List(c *gin.Context) { Status: strings.TrimSpace(c.Query("status")), HasDepositFreeQuota: parseBoolQuery(c.Query("has_deposit_free_quota")), QualityOwnerOnly: parseBoolQuery(c.Query("quality_owner_only")), + DepositFreeQuota: normalizeEnumQuery(c.Query("deposit_free_quota"), "has", "none"), + OwnerType: normalizeEnumQuery(c.Query("owner_type"), "quality", "normal"), + GrowthLevel: strings.TrimSpace(c.Query("growth_level")), } result, err := h.service.List(c.Request.Context(), page, pageSize, query) if err != nil { @@ -60,6 +63,16 @@ func parseBoolQuery(value string) bool { } } +func normalizeEnumQuery(value string, allowed ...string) string { + value = strings.ToLower(strings.TrimSpace(value)) + for _, item := range allowed { + if value == item { + return value + } + } + return "" +} + func (h *Handler) Freeze(c *gin.Context) { adminID, ok := currentAdminID(c) if !ok { diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 0f5b7849..683aac6f 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -52,6 +52,11 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue if err != nil { return nil, err } + if query.GrowthLevel != "" { + if _, ok := rentergrowth.LevelForCode(growthConfig, query.GrowthLevel); !ok { + return nil, ErrInvalidGrowthLevel + } + } offset := (page - 1) * pageSize var rows []userRow listTx := r.db.WithContext(ctx).Table("users AS u"). @@ -99,25 +104,30 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con if query.Status != "" { tx = tx.Where("u.status = ?", query.Status) } - if query.HasDepositFreeQuota { - quotaLevelCodes := growthQuotaLevelCodes(growthConfig) - if minPoints, ok := minimumGrowthQuotaPoints(growthConfig); ok { - tx = tx.Where( - "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN ?", - minPoints, - quotaLevelCodes, - ) - } else if len(quotaLevelCodes) > 0 { - tx = tx.Where( - "u.deposit_free_quota_cent > 0 OR u.renter_growth_level_override IN ?", - quotaLevelCodes, - ) + depositFreeQuota := query.DepositFreeQuota + if depositFreeQuota == "" && query.HasDepositFreeQuota { + depositFreeQuota = "has" + } + if depositFreeQuota != "" { + condition, args := depositFreeQuotaCondition(growthConfig) + if depositFreeQuota == "none" { + tx = tx.Where("NOT ("+condition+")", args...) } else { - tx = tx.Where("u.deposit_free_quota_cent > 0") + tx = tx.Where(condition, args...) } } - if query.QualityOwnerOnly { + ownerType := query.OwnerType + if ownerType == "" && query.QualityOwnerOnly { + ownerType = "quality" + } + switch ownerType { + case "quality": tx = tx.Where("u.quality_owner_status = ?", "active") + case "normal": + tx = tx.Where("u.quality_owner_status <> ?", "active") + } + if query.GrowthLevel != "" { + tx = tx.Where("u.renter_growth_level = ?", query.GrowthLevel) } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) { @@ -135,6 +145,21 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con return tx } +// depositFreeQuotaCondition 返回“有免押额度”的 SQL 条件: +// 人工额度 > 0,或当前成长等级自带免押额度。 +func depositFreeQuotaCondition(cfg rentergrowth.Config) (string, []any) { + quotaLevelCodes := growthQuotaLevelCodes(cfg) + if minPoints, ok := minimumGrowthQuotaPoints(cfg); ok { + return "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN ?", + []any{minPoints, quotaLevelCodes} + } + if len(quotaLevelCodes) > 0 { + return "u.deposit_free_quota_cent > 0 OR u.renter_growth_level_override IN ?", + []any{quotaLevelCodes} + } + return "u.deposit_free_quota_cent > 0", nil +} + func growthQuotaLevelCodes(cfg rentergrowth.Config) []string { codes := make([]string, 0, len(cfg.Levels)) for _, level := range cfg.Levels { diff --git a/backend/internal/modules/adminuser/repository_test.go b/backend/internal/modules/adminuser/repository_test.go index dc764313..48f330b0 100644 --- a/backend/internal/modules/adminuser/repository_test.go +++ b/backend/internal/modules/adminuser/repository_test.go @@ -85,6 +85,128 @@ func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) { } } +func TestApplyUserFilterNoneDepositFreeQuota(t *testing.T) { + db := database.NewDryRunDB() + + stmt := applyUserFilter(db.Table("users AS u"), ListQuery{ + DepositFreeQuota: "none", + }, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + + if !strings.Contains(sql, "NOT (u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN (?,?,?))") { + t.Fatalf("SQL 未包含无免押额度过滤:%s", sql) + } +} + +func TestApplyUserFilterOwnerTypeAndGrowthLevel(t *testing.T) { + db := database.NewDryRunDB() + + stmt := applyUserFilter(db.Table("users AS u"), ListQuery{ + OwnerType: "normal", + GrowthLevel: "diamond", + }, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + + if !strings.Contains(sql, "u.quality_owner_status <> ?") { + t.Fatalf("SQL 未包含普通号主过滤:%s", sql) + } + if !strings.Contains(sql, "u.renter_growth_level = ?") { + t.Fatalf("SQL 未包含成长等级过滤:%s", sql) + } + if len(stmt.Vars) != 2 || stmt.Vars[0] != "active" || stmt.Vars[1] != "diamond" { + t.Fatalf("筛选变量不正确:%v", stmt.Vars) + } +} + +func TestNormalizeEnumQuery(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: " QUALITY ", want: "quality"}, + {input: "normal", want: "normal"}, + {input: "unknown", want: ""}, + {input: "", want: ""}, + } + for _, tt := range tests { + if got := normalizeEnumQuery(tt.input, "quality", "normal"); got != tt.want { + t.Fatalf("normalizeEnumQuery(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestListFiltersByDepositQuotaOwnerTypeAndGrowthLevel(t *testing.T) { + db := setupAdminUserTestDB(t) + users := []model.User{ + {Phone: "13900001001", Nickname: "普通无免押", Status: "active", QualityOwnerStatus: "active", RenterGrowthLevel: "normal"}, + {Phone: "13900001002", Nickname: "铂金有免押", Status: "active", QualityOwnerStatus: "none", RenterGrowthPoints: 300, RenterGrowthLevel: "platinum"}, + {Phone: "13900001003", Nickname: "钻石有免押", Status: "active", QualityOwnerStatus: "none", RenterGrowthPoints: 1000, RenterGrowthLevel: "diamond"}, + {Phone: "13900001004", Nickname: "人工免押", Status: "active", QualityOwnerStatus: "none", DepositFreeQuotaCent: 20000, RenterGrowthLevel: "normal"}, + } + if err := db.Create(&users).Error; err != nil { + t.Fatalf("创建用户失败:%v", err) + } + repo := NewRepository(db) + + tests := []struct { + name string + query ListQuery + wantIDs []uint64 + }{ + { + name: "有免押额度", + query: ListQuery{DepositFreeQuota: "has"}, + wantIDs: []uint64{users[1].ID, users[2].ID, users[3].ID}, + }, + { + name: "无免押额度", + query: ListQuery{DepositFreeQuota: "none"}, + wantIDs: []uint64{users[0].ID}, + }, + { + name: "优质号主", + query: ListQuery{OwnerType: "quality"}, + wantIDs: []uint64{users[0].ID}, + }, + { + name: "普通用户", + query: ListQuery{OwnerType: "normal"}, + wantIDs: []uint64{users[1].ID, users[2].ID, users[3].ID}, + }, + { + name: "铂金等级", + query: ListQuery{GrowthLevel: "platinum"}, + wantIDs: []uint64{users[1].ID}, + }, + { + name: "组合筛选", + query: ListQuery{OwnerType: "normal", GrowthLevel: "normal", DepositFreeQuota: "has"}, + wantIDs: []uint64{users[3].ID}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := repo.List(t.Context(), 1, 20, tt.query) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if result.Total != int64(len(tt.wantIDs)) { + t.Fatalf("Total = %d, want %d", result.Total, len(tt.wantIDs)) + } + items := result.Items.([]UserDTO) + got := make(map[uint64]bool, len(items)) + for _, item := range items { + got[item.ID] = true + } + for _, id := range tt.wantIDs { + if !got[id] { + t.Fatalf("结果缺少用户 %d:%v", id, got) + } + } + }) + } +} + func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) { db := setupAdminUserTestDB(t) user := model.User{ diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index 334d29b8..7f216dc5 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -57,16 +57,21 @@ export interface AdminGrowthLevelAdjustPayload { export interface AdminUserQuery { keyword?: string status?: '' | UserStatus - has_deposit_free_quota?: boolean - quality_owner_only?: boolean + /** has=有免押额度,none=无免押额度,空=全部 */ + deposit_free_quota?: '' | 'has' | 'none' + /** quality=优质号主,normal=普通用户,空=全部 */ + owner_type?: '' | 'quality' | 'normal' + /** 成长等级 code,空=全部 */ + growth_level?: string } export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQuery = {}) { const params: Record = { page, page_size: pageSize } if (query.keyword) params.keyword = query.keyword if (query.status) params.status = query.status - if (query.has_deposit_free_quota) params.has_deposit_free_quota = true - if (query.quality_owner_only) params.quality_owner_only = true + if (query.deposit_free_quota) params.deposit_free_quota = query.deposit_free_quota + if (query.owner_type) params.owner_type = query.owner_type + if (query.growth_level) params.growth_level = query.growth_level const { data } = await apiClient.get>>( '/admin/users', { diff --git a/frontend/src/features/admin/views/AdminUsersView.css b/frontend/src/features/admin/views/AdminUsersView.css index a3c0c565..30ec2fdd 100644 --- a/frontend/src/features/admin/views/AdminUsersView.css +++ b/frontend/src/features/admin/views/AdminUsersView.css @@ -9,6 +9,26 @@ margin-bottom: 16px; } +.user-filter-summary { + display: flex; + align-items: baseline; + gap: 8px; + margin: 0 0 10px 4px; + font-size: 13px; + color: #64748b; +} + +.user-filter-summary strong { + font-size: 18px; + color: #0f172a; + font-variant-numeric: tabular-nums; +} + +.user-filter-summary-hint { + font-size: 12px; + color: #94a3b8; +} + .balance-available { color: #0f172a; font-variant-numeric: tabular-nums; diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index b9ff5e2d..a58d364a 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -75,8 +75,9 @@ const walletForm = reactive({ const filters = reactive({ keyword: '', status: '', - has_deposit_free_quota: false, - quality_owner_only: false, + deposit_free_quota: '', + owner_type: '', + growth_level: '', }) const { @@ -98,8 +99,9 @@ function queryUsers() { function resetFilters() { filters.keyword = '' filters.status = '' - filters.has_deposit_free_quota = false - filters.quality_owner_only = false + filters.deposit_free_quota = '' + filters.owner_type = '' + filters.growth_level = '' queryUsers() } @@ -455,14 +457,44 @@ function moneyCent(value: number | string | undefined) { - - 仅看有额度 - + + + + - - 仅看优质号主 - + + + + + + + + + +
+ 筛选结果 {{ loading ? '...' : total }} 人 + 正在统计 +
+