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`
|
- 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">
|
<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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user