feat(adminuser): 优化用户管理筛选并展示筛选人数
- 免押额度与号主类型改为下拉筛选,支持有/无和优质/普通 - 新增用户成长等级筛选 - 列表上方固定展示筛选结果人数,补充组合筛选测试
This commit is contained in:
@@ -78,6 +78,12 @@ type ListQuery struct {
|
|||||||
Status string
|
Status string
|
||||||
HasDepositFreeQuota bool
|
HasDepositFreeQuota bool
|
||||||
QualityOwnerOnly bool
|
QualityOwnerOnly bool
|
||||||
|
// DepositFreeQuota: has=有免押额度, none=无免押额度, 空=全部。
|
||||||
|
DepositFreeQuota string
|
||||||
|
// OwnerType: quality=优质号主, normal=普通用户, 空=全部。
|
||||||
|
OwnerType string
|
||||||
|
// GrowthLevel: 成长等级 code(normal/platinum/diamond/peak),空=全部。
|
||||||
|
GrowthLevel string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaginatedResult struct {
|
type PaginatedResult struct {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
Status: strings.TrimSpace(c.Query("status")),
|
Status: strings.TrimSpace(c.Query("status")),
|
||||||
HasDepositFreeQuota: parseBoolQuery(c.Query("has_deposit_free_quota")),
|
HasDepositFreeQuota: parseBoolQuery(c.Query("has_deposit_free_quota")),
|
||||||
QualityOwnerOnly: parseBoolQuery(c.Query("quality_owner_only")),
|
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)
|
result, err := h.service.List(c.Request.Context(), page, pageSize, query)
|
||||||
if err != nil {
|
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) {
|
func (h *Handler) Freeze(c *gin.Context) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if query.GrowthLevel != "" {
|
||||||
|
if _, ok := rentergrowth.LevelForCode(growthConfig, query.GrowthLevel); !ok {
|
||||||
|
return nil, ErrInvalidGrowthLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
var rows []userRow
|
var rows []userRow
|
||||||
listTx := r.db.WithContext(ctx).Table("users AS u").
|
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 != "" {
|
if query.Status != "" {
|
||||||
tx = tx.Where("u.status = ?", query.Status)
|
tx = tx.Where("u.status = ?", query.Status)
|
||||||
}
|
}
|
||||||
if query.HasDepositFreeQuota {
|
depositFreeQuota := query.DepositFreeQuota
|
||||||
quotaLevelCodes := growthQuotaLevelCodes(growthConfig)
|
if depositFreeQuota == "" && query.HasDepositFreeQuota {
|
||||||
if minPoints, ok := minimumGrowthQuotaPoints(growthConfig); ok {
|
depositFreeQuota = "has"
|
||||||
tx = tx.Where(
|
}
|
||||||
"u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN ?",
|
if depositFreeQuota != "" {
|
||||||
minPoints,
|
condition, args := depositFreeQuotaCondition(growthConfig)
|
||||||
quotaLevelCodes,
|
if depositFreeQuota == "none" {
|
||||||
)
|
tx = tx.Where("NOT ("+condition+")", args...)
|
||||||
} else if len(quotaLevelCodes) > 0 {
|
|
||||||
tx = tx.Where(
|
|
||||||
"u.deposit_free_quota_cent > 0 OR u.renter_growth_level_override IN ?",
|
|
||||||
quotaLevelCodes,
|
|
||||||
)
|
|
||||||
} else {
|
} 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")
|
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 keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(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
|
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 {
|
func growthQuotaLevelCodes(cfg rentergrowth.Config) []string {
|
||||||
codes := make([]string, 0, len(cfg.Levels))
|
codes := make([]string, 0, len(cfg.Levels))
|
||||||
for _, level := range cfg.Levels {
|
for _, level := range cfg.Levels {
|
||||||
|
|||||||
@@ -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) {
|
func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) {
|
||||||
db := setupAdminUserTestDB(t)
|
db := setupAdminUserTestDB(t)
|
||||||
user := model.User{
|
user := model.User{
|
||||||
|
|||||||
@@ -57,16 +57,21 @@ export interface AdminGrowthLevelAdjustPayload {
|
|||||||
export interface AdminUserQuery {
|
export interface AdminUserQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
status?: '' | UserStatus
|
status?: '' | UserStatus
|
||||||
has_deposit_free_quota?: boolean
|
/** has=有免押额度,none=无免押额度,空=全部 */
|
||||||
quality_owner_only?: boolean
|
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 = {}) {
|
export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQuery = {}) {
|
||||||
const params: Record<string, unknown> = { page, page_size: pageSize }
|
const params: Record<string, unknown> = { page, page_size: pageSize }
|
||||||
if (query.keyword) params.keyword = query.keyword
|
if (query.keyword) params.keyword = query.keyword
|
||||||
if (query.status) params.status = query.status
|
if (query.status) params.status = query.status
|
||||||
if (query.has_deposit_free_quota) params.has_deposit_free_quota = true
|
if (query.deposit_free_quota) params.deposit_free_quota = query.deposit_free_quota
|
||||||
if (query.quality_owner_only) params.quality_owner_only = true
|
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<ApiResponse<PaginatedResult<AdminUserItem>>>(
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>(
|
||||||
'/admin/users',
|
'/admin/users',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,26 @@
|
|||||||
margin-bottom: 16px;
|
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 {
|
.balance-available {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -75,8 +75,9 @@ const walletForm = reactive({
|
|||||||
const filters = reactive<AdminUserQuery>({
|
const filters = reactive<AdminUserQuery>({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
status: '',
|
status: '',
|
||||||
has_deposit_free_quota: false,
|
deposit_free_quota: '',
|
||||||
quality_owner_only: false,
|
owner_type: '',
|
||||||
|
growth_level: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -98,8 +99,9 @@ function queryUsers() {
|
|||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filters.keyword = ''
|
filters.keyword = ''
|
||||||
filters.status = ''
|
filters.status = ''
|
||||||
filters.has_deposit_free_quota = false
|
filters.deposit_free_quota = ''
|
||||||
filters.quality_owner_only = false
|
filters.owner_type = ''
|
||||||
|
filters.growth_level = ''
|
||||||
queryUsers()
|
queryUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,14 +457,44 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="免押额度">
|
<el-form-item label="免押额度">
|
||||||
<el-checkbox v-model="filters.has_deposit_free_quota" @change="queryUsers">
|
<el-select
|
||||||
仅看有额度
|
v-model="filters.deposit_free_quota"
|
||||||
</el-checkbox>
|
clearable
|
||||||
|
placeholder="全部免押额度"
|
||||||
|
style="width: 150px"
|
||||||
|
@change="queryUsers"
|
||||||
|
>
|
||||||
|
<el-option label="有免押额度" value="has" />
|
||||||
|
<el-option label="无免押额度" value="none" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="号主类型">
|
<el-form-item label="号主类型">
|
||||||
<el-checkbox v-model="filters.quality_owner_only" @change="queryUsers">
|
<el-select
|
||||||
仅看优质号主
|
v-model="filters.owner_type"
|
||||||
</el-checkbox>
|
clearable
|
||||||
|
placeholder="全部号主类型"
|
||||||
|
style="width: 150px"
|
||||||
|
@change="queryUsers"
|
||||||
|
>
|
||||||
|
<el-option label="优质号主" value="quality" />
|
||||||
|
<el-option label="普通用户" value="normal" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户等级">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.growth_level"
|
||||||
|
clearable
|
||||||
|
placeholder="全部等级"
|
||||||
|
style="width: 130px"
|
||||||
|
@change="queryUsers"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="level in growthLevelOptions"
|
||||||
|
:key="level.code"
|
||||||
|
:label="level.name"
|
||||||
|
:value="level.code"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :icon="Search" :loading="loading" @click="queryUsers"
|
<el-button type="primary" :icon="Search" :loading="loading" @click="queryUsers"
|
||||||
@@ -473,6 +505,13 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="user-filter-summary">
|
||||||
|
<span
|
||||||
|
>筛选结果 <strong>{{ loading ? '...' : total }}</strong> 人</span
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="user-filter-summary-hint">正在统计</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="users" empty-text="未找到匹配的用户">
|
<el-table v-loading="loading" class="table-panel" :data="users" empty-text="未找到匹配的用户">
|
||||||
<el-table-column prop="id" label="用户ID" width="80" />
|
<el-table-column prop="id" label="用户ID" width="80" />
|
||||||
<el-table-column prop="nickname" label="昵称" min-width="120" />
|
<el-table-column prop="nickname" label="昵称" min-width="120" />
|
||||||
|
|||||||
Reference in New Issue
Block a user