用户管理增加查找筛选
后台用户列表此前仅分页,数据量大时无法定位用户。 - 后端:List 新增 ListQuery(关键词 + 状态),抽出 applyUserFilter 对总数与列表查询统一加筛选;关键词为纯数字时同时按用户 ID 精确匹配, 否则按手机号/昵称模糊匹配 - 前端:AdminUsersView 新增筛选栏(关键词:手机号/昵称/用户ID, 状态:正常/已冻结/已禁用),回车/清空/选择即查,查询重置到第 1 页 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e75a39b03a
commit
a88008f783
@@ -28,6 +28,12 @@ type DepositFreeQuotaRequest struct {
|
|||||||
AmountCent int64 `json:"amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListQuery 用户列表筛选条件。Keyword 同时匹配手机号/昵称(模糊)与用户 ID(精确)。
|
||||||
|
type ListQuery struct {
|
||||||
|
Keyword string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
type PaginatedResult struct {
|
type PaginatedResult struct {
|
||||||
Items interface{} `json:"items"`
|
Items interface{} `json:"items"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/middleware"
|
"hfb_sys/backend/internal/middleware"
|
||||||
"hfb_sys/backend/pkg/response"
|
"hfb_sys/backend/pkg/response"
|
||||||
@@ -36,7 +37,11 @@ func parsePagination(c *gin.Context) (int, int) {
|
|||||||
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
func (h *Handler) List(c *gin.Context) {
|
||||||
page, pageSize := parsePagination(c)
|
page, pageSize := parsePagination(c)
|
||||||
result, err := h.service.List(c.Request.Context(), page, pageSize)
|
query := ListQuery{
|
||||||
|
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||||
|
Status: strings.TrimSpace(c.Query("status")),
|
||||||
|
}
|
||||||
|
result, err := h.service.List(c.Request.Context(), page, pageSize, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAdminUserError(c, err)
|
writeAdminUserError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package adminuser
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
@@ -21,14 +23,15 @@ func NewRepository(db *gorm.DB) *Repository {
|
|||||||
return &Repository{db: db}
|
return &Repository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) List(ctx context.Context, page, pageSize int) (*PaginatedResult, error) {
|
func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
|
||||||
var total int64
|
var total int64
|
||||||
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
|
countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query)
|
||||||
|
if err := countTx.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
var rows []userRow
|
var rows []userRow
|
||||||
err := r.db.WithContext(ctx).Table("users AS u").
|
listTx := r.db.WithContext(ctx).Table("users AS u").
|
||||||
Select(`u.*,
|
Select(`u.*,
|
||||||
COALESCE(o.order_count, 0) AS order_count,
|
COALESCE(o.order_count, 0) AS order_count,
|
||||||
COALESCE(l.listing_count, 0) AS listing_count,
|
COALESCE(l.listing_count, 0) AS listing_count,
|
||||||
@@ -37,7 +40,9 @@ func (r *Repository) List(ctx context.Context, page, pageSize int) (*PaginatedRe
|
|||||||
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.user_id = u.id").
|
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.user_id = u.id").
|
||||||
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
|
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
|
||||||
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
|
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
|
||||||
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
|
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id")
|
||||||
|
listTx = applyUserFilter(listTx, query)
|
||||||
|
err := listTx.
|
||||||
Order("u.id DESC").
|
Order("u.id DESC").
|
||||||
Offset(offset).Limit(pageSize).
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
@@ -51,6 +56,23 @@ func (r *Repository) List(ctx context.Context, page, pageSize int) (*PaginatedRe
|
|||||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyUserFilter 将列表筛选条件应用到查询(基于 users 表别名 u)。
|
||||||
|
func applyUserFilter(tx *gorm.DB, query ListQuery) *gorm.DB {
|
||||||
|
if query.Status != "" {
|
||||||
|
tx = tx.Where("u.status = ?", query.Status)
|
||||||
|
}
|
||||||
|
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
// 关键词为纯数字时一并按用户 ID 精确匹配
|
||||||
|
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
||||||
|
tx = tx.Where("u.id = ? OR u.phone LIKE ? OR u.nickname LIKE ?", id, like, like)
|
||||||
|
} else {
|
||||||
|
tx = tx.Where("u.phone LIKE ? OR u.nickname LIKE ?", like, like)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) Freeze(ctx context.Context, adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
func (r *Repository) Freeze(ctx context.Context, adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
||||||
return r.updateStatus(ctx, adminID, userID, "frozen", "frozen", "admin_user.freeze", req.Reason, meta)
|
return r.updateStatus(ctx, adminID, userID, "frozen", "frozen", "admin_user.freeze", req.Reason, meta)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ func NewService(repo *Repository) *Service {
|
|||||||
return &Service{repo: repo}
|
return &Service{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List(ctx context.Context, page, pageSize int) (*PaginatedResult, error) {
|
func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.List(ctx, page, pageSize)
|
return s.repo.List(ctx, page, pageSize, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Freeze(ctx context.Context, adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
func (s *Service) Freeze(ctx context.Context, adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
||||||
|
|||||||
@@ -22,13 +22,18 @@ export interface AdminUserItem {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminUsers(page = 1, pageSize = 20) {
|
export interface AdminUserQuery {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>(
|
keyword?: string
|
||||||
'/admin/users',
|
status?: '' | UserStatus
|
||||||
{
|
}
|
||||||
params: { page, page_size: pageSize },
|
|
||||||
}
|
export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQuery = {}) {
|
||||||
)
|
const params: Record<string, unknown> = { page, page_size: pageSize }
|
||||||
|
if (query.keyword) params.keyword = query.keyword
|
||||||
|
if (query.status) params.status = query.status
|
||||||
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ref } from 'vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
setAdminUserDepositFreeQuota,
|
setAdminUserDepositFreeQuota,
|
||||||
unfreezeAdminUser,
|
unfreezeAdminUser,
|
||||||
type AdminUserItem,
|
type AdminUserItem,
|
||||||
|
type AdminUserQuery,
|
||||||
} from '@/features/admin/api/adminUsers'
|
} from '@/features/admin/api/adminUsers'
|
||||||
import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money'
|
import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money'
|
||||||
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
||||||
@@ -22,6 +24,11 @@ const quotaUser = ref<AdminUserItem | null>(null)
|
|||||||
const freezeReason = ref('')
|
const freezeReason = ref('')
|
||||||
const quotaAmount = ref(0)
|
const quotaAmount = ref(0)
|
||||||
|
|
||||||
|
const filters = reactive<AdminUserQuery>({
|
||||||
|
keyword: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
data: users,
|
data: users,
|
||||||
@@ -30,9 +37,20 @@ const {
|
|||||||
currentPageSize,
|
currentPageSize,
|
||||||
load: loadUsers,
|
load: loadUsers,
|
||||||
} = useAdminPaginatedTable<AdminUserItem>({
|
} = useAdminPaginatedTable<AdminUserItem>({
|
||||||
fetchFn: fetchAdminUsers,
|
fetchFn: (page, pageSize) => fetchAdminUsers(page, pageSize, filters),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function queryUsers() {
|
||||||
|
currentPage.value = 1
|
||||||
|
void loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.keyword = ''
|
||||||
|
filters.status = ''
|
||||||
|
queryUsers()
|
||||||
|
}
|
||||||
|
|
||||||
function openFreeze(row: AdminUserItem) {
|
function openFreeze(row: AdminUserItem) {
|
||||||
activeUser.value = row
|
activeUser.value = row
|
||||||
freezeReason.value = ''
|
freezeReason.value = ''
|
||||||
@@ -102,7 +120,41 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-button @click="loadUsers">刷新</el-button>
|
<el-button @click="loadUsers">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="users">
|
<div class="user-filter-bar">
|
||||||
|
<el-form inline @submit.prevent>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="手机号 / 昵称 / 用户ID"
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="queryUsers"
|
||||||
|
@clear="queryUsers"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.status"
|
||||||
|
clearable
|
||||||
|
placeholder="全部状态"
|
||||||
|
style="width: 140px"
|
||||||
|
@change="queryUsers"
|
||||||
|
>
|
||||||
|
<el-option label="正常" value="active" />
|
||||||
|
<el-option label="已冻结" value="frozen" />
|
||||||
|
<el-option label="已禁用" value="disabled" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :icon="Search" :loading="loading" @click="queryUsers"
|
||||||
|
>查询</el-button
|
||||||
|
>
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="130" />
|
<el-table-column prop="nickname" label="昵称" min-width="130" />
|
||||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||||
@@ -209,3 +261,16 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-filter-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px 16px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-filter-bar :deep(.el-form-item) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user