feat: 支持会员管理筛选

This commit is contained in:
richarjiang
2026-04-07 09:22:58 +08:00
parent 58c7588a96
commit 0ca93ec97e
6 changed files with 129 additions and 8 deletions

View File

@@ -66,4 +66,23 @@ pnpm deploy:server # 部署后端到生产环境
### 数据库 ### 数据库
- Prisma schema 位于 `packages/server/prisma/schema.prisma` - Prisma schema 位于 `packages/server/prisma/schema.prisma`
- 核心数据模型User(用户)、Studio(场馆)、TimeSlot(时段)、Booking(预约)、Membership(会员卡)、CardType(卡种)、Order(订单) - 核心数据模型User、Studio、TimeSlot、Booking、Membership、CardType、Order
- 注意查询会员列表时booking 统计通过 `groupBy` 批量获取,避免 N+1 查询
### 卡类型枚举
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
- 前端选项硬编码在 `src/pages/admin/members.vue``cardTypeOptions`,需与枚举保持同步
### 管理后台 API 模式
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
- 预约统计total/completed/cancelled通过 `groupBy` 批量查询
### 筛选组件模式
- picker 筛选使用 300ms debounce 再触发加载,避免频繁请求
- 列表分页使用 `onReachBottom` + `hasMore` 标志位实现无限滚动
### Admin Store (`src/stores/admin.ts`)
- 聚合所有管理端 API 调用weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`

View File

@@ -15,6 +15,19 @@
<view v-if="searchQuery" class="search-clear" @tap="onClear"> <view v-if="searchQuery" class="search-clear" @tap="onClear">
<text class="search-clear-icon">×</text> <text class="search-clear-icon">×</text>
</view> </view>
<picker
class="type-picker"
mode="selector"
:value="cardTypeIndex"
:range="cardTypeOptions"
range-key="label"
@change="onCardTypeChange"
>
<view class="type-picker-inner">
<text class="type-picker-text">{{ cardTypeOptions[cardTypeIndex].label }}</text>
<text class="type-picker-arrow"></text>
</view>
</picker>
<view class="search-btn" @tap="onSearch"> <view class="search-btn" @tap="onSearch">
<text class="search-btn-text">搜索</text> <text class="search-btn-text">搜索</text>
</view> </view>
@@ -115,7 +128,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app' import { onReachBottom } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
@@ -140,6 +153,29 @@ const detailMember = ref<MemberSummary | null>(null)
const LIMIT = 20 const LIMIT = 20
const cardTypeOptions = [
{ label: '全部', value: '' },
{ label: '体验卡', value: 'TRIAL' },
{ label: '次卡', value: 'TIMES' },
{ label: '月卡', value: 'DURATION' },
{ label: '无卡', value: 'NONE' },
]
const cardTypeIndex = ref(0)
let cardTypeDebounceTimer: ReturnType<typeof setTimeout> | null = null
function onCardTypeChange(e: { detail: { value: number } }) {
cardTypeIndex.value = e.detail.value
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
cardTypeDebounceTimer = setTimeout(() => {
loadMembers(true)
cardTypeDebounceTimer = null
}, 300)
}
onUnmounted(() => {
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
})
async function loadMembers(reset = false) { async function loadMembers(reset = false) {
if (loading.value) return if (loading.value) return
if (reset) { if (reset) {
@@ -149,10 +185,12 @@ async function loadMembers(reset = false) {
loading.value = true loading.value = true
try { try {
const search = searchQuery.value.trim() const search = searchQuery.value.trim()
const cardType = cardTypeOptions[cardTypeIndex.value].value
const result = await adminStore.fetchMembers({ const result = await adminStore.fetchMembers({
page: page.value, page: page.value,
limit: LIMIT, limit: LIMIT,
...(search ? { search } : {}), ...(search ? { search } : {}),
...(cardType ? { cardType } : {}),
}) })
if (reset) { if (reset) {
members.value = [...result.items] members.value = [...result.items]
@@ -229,7 +267,7 @@ onMounted(() => loadMembers(true))
.search-clear { .search-clear {
position: absolute; position: absolute;
right: 168rpx; right: 260rpx;
width: 44rpx; width: 44rpx;
height: 44rpx; height: 44rpx;
display: flex; display: flex;
@@ -251,6 +289,37 @@ onMounted(() => loadMembers(true))
.search-btn-text { font-size: 26rpx; font-weight: 600; color: $accent-color; } .search-btn-text { font-size: 26rpx; font-weight: 600; color: $accent-color; }
/* ── Type picker ──────────────────────────── */
.type-picker {
flex-shrink: 0;
background: $bg-page;
border-radius: 36rpx;
padding: 0 20rpx;
height: 72rpx;
display: flex;
align-items: center;
}
.type-picker-inner {
display: flex;
align-items: center;
gap: 8rpx;
}
.type-picker-text {
font-size: 24rpx;
color: $text-secondary;
max-width: 80rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.type-picker-arrow {
font-size: 20rpx;
color: $text-hint;
}
/* ── Stats row ───────────────────────────── */ /* ── Stats row ───────────────────────────── */
.stats-row { .stats-row {
display: flex; display: flex;

View File

@@ -115,12 +115,13 @@ export const useAdminStore = defineStore('admin', () => {
page?: number page?: number
limit?: number limit?: number
search?: string search?: string
cardType?: string
}): Promise<PaginatedData<MemberSummary>> { }): Promise<PaginatedData<MemberSummary>> {
// Filter out undefined/empty values to avoid sending "undefined" as string
const cleanParams: Record<string, unknown> = {} const cleanParams: Record<string, unknown> = {}
if (params?.page != null) cleanParams.page = params.page if (params?.page != null) cleanParams.page = params.page
if (params?.limit != null) cleanParams.limit = params.limit if (params?.limit != null) cleanParams.limit = params.limit
if (params?.search) cleanParams.search = params.search if (params?.search) cleanParams.search = params.search
if (params?.cardType) cleanParams.cardType = params.cardType
return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams) return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams)
} }

View File

@@ -6,7 +6,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common' } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared' import { UserRole, CardTypeCategory } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard' import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard' import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator' import { Roles } from '../auth/roles.decorator'
@@ -14,6 +14,8 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
import { UserService } from './user.service' import { UserService } from './user.service'
import { UpdateProfileDto } from './dto/update-profile.dto' import { UpdateProfileDto } from './dto/update-profile.dto'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller() @Controller()
export class UserController { export class UserController {
@@ -46,11 +48,17 @@ export class UserController {
@Query('page') page?: string, @Query('page') page?: string,
@Query('limit') limit?: string, @Query('limit') limit?: string,
@Query('search') search?: string, @Query('search') search?: string,
@Query('cardType') cardType?: string,
) { ) {
const validCardType =
cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE')
? cardType
: undefined
return this.userService.getMembers( return this.userService.getMembers(
page ? Number(page) : 1, page ? Number(page) : 1,
limit ? Number(limit) : 20, limit ? Number(limit) : 20,
search && search !== 'undefined' ? search : undefined, search && search !== 'undefined' ? search : undefined,
validCardType,
) )
} }
} }

View File

@@ -1,8 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common' import { Injectable, NotFoundException } from '@nestjs/common'
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared' import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared' import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
@Injectable() @Injectable()
export class UserService { export class UserService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -124,6 +126,7 @@ export class UserService {
page: number, page: number,
limit: number, limit: number,
search?: string, search?: string,
cardType?: string,
): Promise<PaginatedData<{ ): Promise<PaginatedData<{
userId: string userId: string
openid: string openid: string
@@ -134,7 +137,16 @@ export class UserService {
completedBookings: number completedBookings: number
cancelledBookings: number cancelledBookings: number
}>> { }>> {
const where = search const where: {
OR?: Array<{ [key: string]: unknown }>
memberships?: {
some: {
status: MembershipStatus
cardType?: { type: CardTypeCategory }
}
}
NOT?: { memberships?: { some: { status: MembershipStatus } } }
} = search
? { ? {
OR: [ OR: [
{ nickname: { contains: search, mode: 'insensitive' as const } }, { nickname: { contains: search, mode: 'insensitive' as const } },
@@ -144,6 +156,18 @@ export class UserService {
} }
: {} : {}
// cardType filter: NONE = no active membership, otherwise filter by card type category
if (cardType === 'NONE') {
where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } }
} else if (cardType && VALID_CARD_TYPES.has(cardType)) {
where.memberships = {
some: {
status: MembershipStatus.ACTIVE,
cardType: { type: cardType as CardTypeCategory },
},
}
}
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([
this.prisma.user.findMany({ this.prisma.user.findMany({
where, where,

File diff suppressed because one or more lines are too long