# 会员卡编辑功能实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在管理后台会员列表中,点击会员弹出详情/编辑弹窗,支持编辑卡种、有效期、次数,以及清空会员卡 **Architecture:** 后端在 `UserController` 新增三个 admin 接口(GET/PUT/DELETE `/admin/members/:userId/membership`),前端将现有详情弹窗改造为 Tab 模式,store 新增三个 action **Tech Stack:** NestJS (后端) + Vue 3 + uni-app + Pinia (前端) --- ## 文件结构 ``` Backend: - packages/server/src/user/user.controller.ts (新增3个接口) - packages/server/src/user/user.service.ts (新增3个 service 方法) - packages/server/src/user/dto/update-user-membership.dto.ts (新建) Frontend: - packages/app/src/stores/admin.ts (新增3个 action) - packages/app/src/pages/admin/members.vue (改造详情弹窗为 Tab 模式) ``` --- ## Phase 1: 后端接口 ### Task 1: 创建 UpdateUserMembershipDto **Files:** - Create: `packages/server/src/user/dto/update-user-membership.dto.ts` ```typescript import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator' export class UpdateUserMembershipDto { @IsUUID() cardTypeId: string @IsOptional() @IsInt() @Min(0) remainingTimes?: number | null @IsDateString() startDate: string @IsDateString() expireDate: string } ``` - [ ] **Step 1: 创建目录并写入文件** ```bash mkdir -p /Users/richard/Documents/code/pilates/mp-pilates/packages/server/src/user/dto ``` 写入 `packages/server/src/user/dto/update-user-membership.dto.ts` --- ### Task 2: UserService 新增会员卡管理方法 **Files:** - Modify: `packages/server/src/user/user.service.ts` — 在文件末尾添加 3 个新方法 需要添加的方法: 1. `getUserMembership(userId: string)` — 返回 `Membership & { cardType: CardType } | null` 2. `updateUserMembership(userId: string, dto: UpdateUserMembershipDto)` — 创建或更新 membership,status 自动计算 3. `deleteUserMembership(userId: string)` — 软删除(status → EXPIRED) 关键业务逻辑: - `updateUserMembership` 中 status 计算规则: - `expireDate < now` → `EXPIRED` - `remainingTimes === 0` → `USED_UP` - 否则 → `ACTIVE` - 如果用户已有 membership → 更新;无 → 创建新的 - 需要同时 import `CardTypeCategory` 和 `MembershipStatus` ```typescript // 在 user.service.ts 顶部已有 import,追加方法 import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared' import { UpdateUserMembershipDto } from './dto/update-user-membership.dto' import { Membership, CardType, Prisma } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' // 在 class 末尾添加: async getUserMembership(userId: string): Promise<(Membership & { cardType: CardType }) | null> { // userId 无唯一约束,取第一条 const membership = await this.prisma.membership.findFirst({ where: { userId }, include: { cardType: true }, }) return membership ? { ...membership, cardType: { ...membership.cardType } } : null } async updateUserMembership( userId: string, dto: UpdateUserMembershipDto, ): Promise { // 计算 status const now = new Date() const expireDate = new Date(dto.expireDate) const remainingTimes = dto.remainingTimes ?? null const isTimeBased = remainingTimes !== null let status: MembershipStatus = MembershipStatus.ACTIVE if (expireDate < now) { status = MembershipStatus.EXPIRED } else if (isTimeBased && remainingTimes <= 0) { status = MembershipStatus.USED_UP } // 由于 userId 无唯一约束,先查是否存在,取第一条 const existing = await this.prisma.membership.findFirst({ where: { userId } }) let membership: Membership & { cardType: CardType } if (existing) { // 已有 membership → 更新 const updated = await this.prisma.membership.update({ where: { id: existing.id }, data: { cardTypeId: dto.cardTypeId, remainingTimes: dto.remainingTimes ?? null, startDate: new Date(dto.startDate), expireDate: new Date(dto.expireDate), status, }, include: { cardType: true }, }) membership = { ...updated, cardType: { ...updated.cardType } } } else { // 无 membership → 创建新的 const created = await this.prisma.membership.create({ data: { userId, cardTypeId: dto.cardTypeId, remainingTimes: dto.remainingTimes ?? null, startDate: new Date(dto.startDate), expireDate: new Date(dto.expireDate), status, }, include: { cardType: true }, }) membership = { ...created, cardType: { ...created.cardType } } } return membership } async deleteUserMembership(userId: string): Promise { await this.prisma.membership.updateMany({ where: { userId }, data: { status: MembershipStatus.EXPIRED }, }) } ``` 注意:`Membership` 表的 `userId` 暂无唯一约束,实现阶段如有需要可加。`findFirst` + `update` 的方案在只有一张卡时工作正常。 --- ### Task 3: UserController 新增 3 个 admin 接口 **Files:** - Modify: `packages/server/src/user/user.controller.ts` 在文件顶部添加 `@Param` import,然后在 `Admin: Member Management` 区块后添加新接口: ```typescript import { Controller, Get, Put, Delete, Body, Param, Query, UseGuards, } from '@nestjs/common' // ... existing imports ... import { UpdateUserMembershipDto } from './dto/update-user-membership.dto' // 在 getMembers 之后添加: @Get('admin/members/:userId/membership') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) getUserMembership(@Param('userId') userId: string) { return this.userService.getUserMembership(userId) } @Put('admin/members/:userId/membership') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) updateUserMembership( @Param('userId') userId: string, @Body() dto: UpdateUserMembershipDto, ) { return this.userService.updateUserMembership(userId, dto) } @Delete('admin/members/:userId/membership') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) deleteUserMembership(@Param('userId') userId: string) { return this.userService.deleteUserMembership(userId) } ``` --- ## Phase 2: 前端 Store ### Task 4: Admin Store 新增 3 个 action **Files:** - Modify: `packages/app/src/stores/admin.ts` 在 `MemberSummary` 类型后添加 `UserMembership` 类型(放在 fetchMembers 附近): ```typescript export interface UserMembership { userId: string membership: { id: string cardTypeId: string remainingTimes: number | null startDate: string expireDate: string status: string cardType: { id: string name: string type: string totalTimes: number | null durationDays: number } } | null } ``` 在 `fetchMembers` action 后添加: ```typescript async function getUserMembership(userId: string): Promise { return get(`/admin/members/${userId}/membership`) } async function updateUserMembership( userId: string, dto: { cardTypeId: string remainingTimes?: number | null startDate: string expireDate: string }, ): Promise { return put(`/admin/members/${userId}/membership`, dto) } async function deleteUserMembership(userId: string): Promise { return del(`/admin/members/${userId}/membership`) } ``` 在 `return` 对象中导出这三个函数。 --- ## Phase 3: 前端 UI(详情弹窗改造) ### Task 5: members.vue 弹窗改造 — 结构变更 **Files:** - Modify: `packages/app/src/pages/admin/members.vue` 将现有的详情弹窗(`detail-modal`)从单一视图改造为 Tab 模式。 #### 5.1 模板改动 **原有弹窗代码:** ```vue ``` **替换为:** ```vue 会员详情 × 详情 编辑 {{ (detailMember.nickname || '?').slice(0, 1) }} {{ detailMember.nickname || '未知用户' }} {{ detailMember.openid }} {{ detailMember.phone || '未绑定手机' }} {{ detailMembership.cardType.name }} {{ statusLabel(detailMembership.status) }} 有效期:{{ formatDate(detailMembership.startDate) }} - {{ formatDate(detailMembership.expireDate) }} 剩余:{{ detailMembership.remainingTimes }} 次 暂无会员卡 去开卡 {{ detailMember.totalBookings }} 总预约 {{ detailMember.completedBookings }} 已完成 {{ detailMember.cancelledBookings }} 已取消 解除会员卡 加载中... 卡种 {{ editCardTypes[editForm.cardTypeIndex]?.name || '请选择' }} 剩余次数 生效日期 {{ editForm.startDate }} 有效期至 {{ editForm.expireDate }} {{ editSubmitting ? '保存中...' : '保存' }} ``` #### 5.2 Script 改动 新增以下状态: > 注意:在 `import { ref, onMounted, onUnmounted }` 后添加 `computed`: > `import { ref, computed, onMounted, onUnmounted } from 'vue'` ```typescript const activeTab = ref<'detail' | 'edit'>('detail') const detailMembership = ref(null) // 当前会员卡数据 const editLoading = ref(false) const editSubmitting = ref(false) const editCardTypes = ref([]) // 卡种列表(去获取) const editForm = ref({ cardTypeIndex: 0, cardTypeId: '', remainingTimes: null as number | null, startDate: '', expireDate: '', manuallyEditedExpire: false, // 用户手动修改过 expireDate 时为 true }) // 是否为次数卡(TIMES / TRIAL),依赖 cardTypeIndex const isTimeBasedCard = computed(() => { const card = editCardTypes.value[editForm.value.cardTypeIndex] return card && (card.type === 'TIMES' || card.type === 'TRIAL') }) // 工具函数 function formatDate(dateStr: string): string { if (!dateStr) return '-' return dateStr.slice(0, 10) } function statusLabel(status: string): string { const map: Record = { ACTIVE: '有效', EXPIRED: '已过期', USED_UP: '已用完', } return map[status] || status } // 懒加载会员卡数据 async function loadDetailMembership(userId: string) { detailMembership.value = null try { const result = await adminStore.getUserMembership(userId) detailMembership.value = result.membership } catch {} } function switchTab(tab: 'detail' | 'edit') { activeTab.value = tab if (tab === 'edit') { // 切换到编辑 Tab 时加载会员卡数据和卡种列表 if (!editCardTypes.value.length) { adminStore.fetchCardTypes().then((types) => { editCardTypes.value = types }) } // 如果还没加载 membership,先加载 if (!detailMembership.value) { loadDetailMembership(detailMember.value!.userId).then(() => { initEditForm() }) } else { initEditForm() } } } function initEditForm() { const m = detailMembership.value const types = editCardTypes.value if (m) { // 有卡:预填充 const idx = types.findIndex((t) => t.id === m.cardTypeId) editForm.value = { cardTypeIndex: idx >= 0 ? idx : 0, cardTypeId: m.cardTypeId, remainingTimes: m.remainingTimes, startDate: m.startDate.slice(0, 10), expireDate: m.expireDate.slice(0, 10), manuallyEditedExpire: false, } } else { // 无卡:默认选中第一个卡种 editForm.value = { cardTypeIndex: 0, cardTypeId: types[0]?.id || '', remainingTimes: types[0]?.totalTimes ?? null, startDate: formatDate2(new Date()), expireDate: calculateExpireDate(formatDate2(new Date()), types[0]?.durationDays ?? 30), manuallyEditedExpire: false, } } } function formatDate2(date: Date): string { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return `${y}-${m}-${d}` } function calculateExpireDate(startDate: string, durationDays: number): string { const d = new Date(startDate) d.setDate(d.getDate() + durationDays) return formatDate2(d) } function onCardTypeChange(e: { detail: { value: number } }) { const idx = e.detail.value const cardType = editCardTypes.value[idx] editForm.value.cardTypeIndex = idx editForm.value.cardTypeId = cardType.id // 自动填充次数和有效期(仅当用户未手动修改过有效期时) if (cardType.totalTimes != null) { editForm.value.remainingTimes = cardType.totalTimes } if (!editForm.value.manuallyEditedExpire) { editForm.value.startDate = formatDate2(new Date()) editForm.value.expireDate = calculateExpireDate( formatDate2(new Date()), cardType.durationDays, ) } } function onStartDateChange(e: { detail: { value: string } }) { editForm.value.startDate = e.detail.value if (!editForm.value.manuallyEditedExpire) { const cardType = editCardTypes.value[editForm.value.cardTypeIndex] if (cardType) { editForm.value.expireDate = calculateExpireDate(e.detail.value, cardType.durationDays) } } } function onExpireDateChange(e: { detail: { value: string } }) { editForm.value.expireDate = e.detail.value editForm.value.manuallyEditedExpire = true } function goEdit() { switchTab('edit') } async function onSaveMembership() { if (editSubmitting.value) return const userId = detailMember.value?.userId if (!userId) return editSubmitting.value = true try { await adminStore.updateUserMembership(userId, { cardTypeId: editForm.value.cardTypeId, remainingTimes: editForm.value.remainingTimes, startDate: editForm.value.startDate, expireDate: editForm.value.expireDate, }) uni.showToast({ title: '保存成功', icon: 'success' }) activeTab.value = 'detail' await loadDetailMembership(userId) // 刷新列表 loadMembers(false) } catch { uni.showToast({ title: '保存失败', icon: 'none' }) } finally { editSubmitting.value = false } } async function onClearMembership() { const userId = detailMember.value?.userId if (!userId) return uni.showModal({ title: '确认解除', content: '确定要解除该用户的会员卡吗?', confirmColor: '#e64329', success: async (res) => { if (!res.confirm) return try { await adminStore.deleteUserMembership(userId) uni.showToast({ title: '已解除', icon: 'success' }) showDetail.value = false loadMembers(false) } catch { uni.showToast({ title: '操作失败', icon: 'none' }) } }, }) } function closeModal() { showDetail.value = false activeTab.value = 'detail' detailMembership.value = null } // 修改 openDetail function openDetail(m: MemberSummary) { detailMember.value = m showDetail.value = true detailMembership.value = null // 重置,懒加载 activeTab.value = 'detail' // 预加载 membership(详情 Tab 需要显示) loadDetailMembership(m.userId) } ``` #### 5.3 样式改动 在 `