feat: 支持会员管理筛选
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -66,4 +66,23 @@ pnpm deploy:server # 部署后端到生产环境
|
||||
|
||||
### 数据库
|
||||
- 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]`
|
||||
@@ -15,6 +15,19 @@
|
||||
<view v-if="searchQuery" class="search-clear" @tap="onClear">
|
||||
<text class="search-clear-icon">×</text>
|
||||
</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">
|
||||
<text class="search-btn-text">搜索</text>
|
||||
</view>
|
||||
@@ -115,7 +128,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { onReachBottom } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
@@ -140,6 +153,29 @@ const detailMember = ref<MemberSummary | null>(null)
|
||||
|
||||
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) {
|
||||
if (loading.value) return
|
||||
if (reset) {
|
||||
@@ -149,10 +185,12 @@ async function loadMembers(reset = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
const search = searchQuery.value.trim()
|
||||
const cardType = cardTypeOptions[cardTypeIndex.value].value
|
||||
const result = await adminStore.fetchMembers({
|
||||
page: page.value,
|
||||
limit: LIMIT,
|
||||
...(search ? { search } : {}),
|
||||
...(cardType ? { cardType } : {}),
|
||||
})
|
||||
if (reset) {
|
||||
members.value = [...result.items]
|
||||
@@ -229,7 +267,7 @@ onMounted(() => loadMembers(true))
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 168rpx;
|
||||
right: 260rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
display: flex;
|
||||
@@ -251,6 +289,37 @@ onMounted(() => loadMembers(true))
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
|
||||
@@ -115,12 +115,13 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
cardType?: string
|
||||
}): Promise<PaginatedData<MemberSummary>> {
|
||||
// Filter out undefined/empty values to avoid sending "undefined" as string
|
||||
const cleanParams: Record<string, unknown> = {}
|
||||
if (params?.page != null) cleanParams.page = params.page
|
||||
if (params?.limit != null) cleanParams.limit = params.limit
|
||||
if (params?.search) cleanParams.search = params.search
|
||||
if (params?.cardType) cleanParams.cardType = params.cardType
|
||||
return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../auth/roles.guard'
|
||||
import { Roles } from '../auth/roles.decorator'
|
||||
@@ -14,6 +14,8 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { UserService } from './user.service'
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto'
|
||||
|
||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller()
|
||||
export class UserController {
|
||||
@@ -46,11 +48,17 @@ export class UserController {
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: 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(
|
||||
page ? Number(page) : 1,
|
||||
limit ? Number(limit) : 20,
|
||||
search && search !== 'undefined' ? search : undefined,
|
||||
validCardType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { PrismaService } from '../prisma/prisma.service'
|
||||
|
||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -124,6 +126,7 @@ export class UserService {
|
||||
page: number,
|
||||
limit: number,
|
||||
search?: string,
|
||||
cardType?: string,
|
||||
): Promise<PaginatedData<{
|
||||
userId: string
|
||||
openid: string
|
||||
@@ -134,7 +137,16 @@ export class UserService {
|
||||
completedBookings: 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: [
|
||||
{ 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([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user