From 13b75c3bede15ce14ab5b57210007e40218ad135 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 7 Apr 2026 09:39:43 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=8D=A1=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-07-member-card-edit.md | 946 ++++++++++++++++++ 1 file changed, 946 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-member-card-edit.md diff --git a/docs/superpowers/plans/2026-04-07-member-card-edit.md b/docs/superpowers/plans/2026-04-07-member-card-edit.md new file mode 100644 index 0000000..534ab64 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-member-card-edit.md @@ -0,0 +1,946 @@ +# 会员卡编辑功能实现计划 + +> **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 样式改动 + +在 `