25 KiB
会员卡编辑功能实现计划
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
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: 创建目录并写入文件
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 个新方法
需要添加的方法:
getUserMembership(userId: string)— 返回Membership & { cardType: CardType } | nullupdateUserMembership(userId: string, dto: UpdateUserMembershipDto)— 创建或更新 membership,status 自动计算deleteUserMembership(userId: string)— 软删除(status → EXPIRED)
关键业务逻辑:
updateUserMembership中 status 计算规则:expireDate < now→EXPIREDremainingTimes === 0→USED_UP- 否则 →
ACTIVE
- 如果用户已有 membership → 更新;无 → 创建新的
- 需要同时 import
CardTypeCategory和MembershipStatus
// 在 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<Membership & { cardType: CardType }> {
// 计算 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<void> {
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 区块后添加新接口:
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 附近):
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 后添加:
async function getUserMembership(userId: string): Promise<UserMembership> {
return get<UserMembership>(`/admin/members/${userId}/membership`)
}
async function updateUserMembership(
userId: string,
dto: {
cardTypeId: string
remainingTimes?: number | null
startDate: string
expireDate: string
},
): Promise<any> {
return put<any>(`/admin/members/${userId}/membership`, dto)
}
async function deleteUserMembership(userId: string): Promise<void> {
return del<void>(`/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 模板改动
原有弹窗代码:
<!-- Detail modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
<view class="modal">
<!-- 详情内容 -->
</view>
</view>
替换为:
<!-- Detail / Edit modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="closeModal">
<view class="modal">
<!-- Header with close -->
<view class="modal-header">
<text class="modal-title">会员详情</text>
<text class="modal-close-btn" @tap="closeModal">×</text>
</view>
<!-- Tab bar -->
<view class="tab-bar">
<view
class="tab-item"
:class="{ 'tab-item--active': activeTab === 'detail' }"
@tap="switchTab('detail')"
>
<text class="tab-text">详情</text>
</view>
<view
class="tab-item"
:class="{ 'tab-item--active': activeTab === 'edit' }"
@tap="switchTab('edit')"
>
<text class="tab-text">编辑</text>
</view>
</view>
<!-- Tab: 详情 -->
<view v-if="activeTab === 'detail'" class="tab-content">
<!-- 头像区 -->
<view class="detail-header">
<view class="detail-avatar">
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
<view v-else class="avatar-placeholder avatar-placeholder--lg">
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
</view>
</view>
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
<text class="detail-openid" @tap="copyOpenid(detailMember.openid)">{{ detailMember.openid }}</text>
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
</view>
<!-- 会员卡信息(有卡时) -->
<view v-if="detailMembership" class="membership-card">
<view class="membership-card-header">
<text class="membership-card-name">{{ detailMembership.cardType.name }}</text>
<text class="membership-card-badge" :class="'badge--' + detailMembership.status">
{{ statusLabel(detailMembership.status) }}
</text>
</view>
<view class="membership-card-info">
<text class="membership-info-item">有效期:{{ formatDate(detailMembership.startDate) }} - {{ formatDate(detailMembership.expireDate) }}</text>
<text v-if="detailMembership.remainingTimes != null" class="membership-info-item">
剩余:{{ detailMembership.remainingTimes }} 次
</text>
</view>
</view>
<!-- 无卡时 -->
<view v-else class="no-membership">
<text class="no-membership-text">暂无会员卡</text>
<view class="no-membership-btn" @tap="goEdit">
<text class="no-membership-btn-text">去开卡</text>
</view>
</view>
<!-- 预约统计 -->
<view class="detail-stats">
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
<text class="detail-stat-label">总预约</text>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
<text class="detail-stat-label">已完成</text>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
<text class="detail-stat-label">已取消</text>
</view>
</view>
<!-- 清空卡按钮(有卡时) -->
<view v-if="detailMembership" class="danger-zone">
<view class="danger-btn" @tap="onClearMembership">
<text class="danger-btn-text">解除会员卡</text>
</view>
</view>
</view>
<!-- Tab: 编辑 -->
<view v-if="activeTab === 'edit'" class="tab-content">
<!-- 加载中 -->
<view v-if="editLoading" class="edit-loading">
<text class="edit-loading-text">加载中...</text>
</view>
<!-- 编辑表单 -->
<view v-else class="edit-form">
<!-- 卡种选择 -->
<view class="form-item">
<text class="form-label">卡种</text>
<picker
class="form-picker"
mode="selector"
:value="editForm.cardTypeIndex"
:range="editCardTypes"
range-key="name"
@change="onCardTypeChange"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editCardTypes[editForm.cardTypeIndex]?.name || '请选择' }}</text>
<text class="form-picker-arrow">▾</text>
</view>
</picker>
</view>
<!-- 剩余次数(TIMES/TRIAL) -->
<view v-if="isTimeBasedCard" class="form-item">
<text class="form-label">剩余次数</text>
<input
class="form-input"
type="number"
v-model="editForm.remainingTimes"
placeholder="请输入剩余次数"
/>
</view>
<!-- 生效日期 -->
<view class="form-item">
<text class="form-label">生效日期</text>
<picker
class="form-picker"
mode="date"
:value="editForm.startDate"
@change="(e) => onStartDateChange(e)"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editForm.startDate }}</text>
</view>
</picker>
</view>
<!-- 有效期至 -->
<view class="form-item">
<text class="form-label">有效期至</text>
<picker
class="form-picker"
mode="date"
:value="editForm.expireDate"
@change="(e) => onExpireDateChange(e)"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editForm.expireDate }}</text>
</view>
</picker>
</view>
<!-- 保存按钮 -->
<view class="edit-submit">
<view
class="submit-btn"
:class="{ 'submit-btn--disabled': editSubmitting }"
@tap="onSaveMembership"
>
<text class="submit-btn-text">{{ editSubmitting ? '保存中...' : '保存' }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
5.2 Script 改动
新增以下状态:
注意:在
import { ref, onMounted, onUnmounted }后添加computed:import { ref, computed, onMounted, onUnmounted } from 'vue'
const activeTab = ref<'detail' | 'edit'>('detail')
const detailMembership = ref<any>(null) // 当前会员卡数据
const editLoading = ref(false)
const editSubmitting = ref(false)
const editCardTypes = ref<any[]>([]) // 卡种列表(去获取)
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<string, string> = {
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 样式改动
在 <style> 末尾添加以下样式:
/* ── Modal header ─────────────────────────── */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 32rpx 0;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: $brand-color;
}
.modal-close-btn {
font-size: 48rpx;
color: $text-hint;
line-height: 1;
}
/* ── Tab bar ───────────────────────────────── */
.tab-bar {
display: flex;
border-bottom: 1rpx solid $border-color;
margin-bottom: 32rpx;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 16rpx;
position: relative;
}
.tab-item--active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background: $accent-color;
border-radius: 2rpx;
}
.tab-text {
font-size: 28rpx;
color: $text-hint;
}
.tab-item--active .tab-text {
color: $accent-color;
font-weight: 600;
}
.tab-content {
min-height: 400rpx;
}
/* ── Membership card ────────────────────────── */
.membership-card {
background: linear-gradient(135deg, rgba($brand-color, 0.08), rgba($accent-color, 0.08));
border: 1rpx solid rgba($brand-color, 0.15);
border-radius: $radius-md;
padding: 24rpx;
margin-bottom: 24rpx;
}
.membership-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.membership-card-name {
font-size: 30rpx;
font-weight: 700;
color: $brand-color;
}
.membership-card-badge {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.badge--ACTIVE { background: rgba(#52c41a, 0.12); color: #52c41a; }
.badge--EXPIRED { background: rgba($text-hint, 0.12); color: $text-hint; }
.badge--USED_UP { background: rgba(#faad14, 0.12); color: #faad14; }
.membership-card-info {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.membership-info-item {
font-size: 24rpx;
color: $text-secondary;
}
/* ── No membership ──────────────────────────── */
.no-membership {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
gap: 24rpx;
}
.no-membership-text {
font-size: 28rpx;
color: $text-hint;
}
.no-membership-btn {
background: $brand-color;
border-radius: 36rpx;
padding: 16rpx 48rpx;
}
.no-membership-btn-text {
font-size: 26rpx;
font-weight: 600;
color: $accent-color;
}
/* ── Danger zone ───────────────────────────── */
.danger-zone {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid $border-color;
}
.danger-btn {
width: 100%;
height: 80rpx;
background: rgba(230, 67, 41, 0.08);
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.danger-btn-text {
font-size: 26rpx;
color: #e64329;
}
/* ── Edit form ─────────────────────────────── */
.edit-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
.edit-loading-text {
font-size: 26rpx;
color: $text-hint;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.form-item {
display: flex;
align-items: center;
gap: 20rpx;
}
.form-label {
font-size: 26rpx;
color: $text-secondary;
width: 140rpx;
flex-shrink: 0;
}
.form-input {
flex: 1;
height: 72rpx;
background: $bg-page;
border-radius: $radius-sm;
padding: 0 20rpx;
font-size: 26rpx;
color: $text-primary;
}
.form-picker {
flex: 1;
height: 72rpx;
background: $bg-page;
border-radius: $radius-sm;
display: flex;
align-items: center;
}
.form-picker-inner {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20rpx;
}
.form-picker-text {
font-size: 26rpx;
color: $text-primary;
}
.form-picker-arrow {
font-size: 20rpx;
color: $text-hint;
}
.edit-submit {
margin-top: 16rpx;
}
.submit-btn {
width: 100%;
height: 88rpx;
background: $brand-color;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.submit-btn--disabled {
opacity: 0.5;
}
.submit-btn-text {
font-size: 28rpx;
font-weight: 600;
color: $accent-color;
}
自检清单
- 后端接口路径正确:
/admin/members/:userId/membership(不是/admin/membership/...) UpdateUserMembershipDto字段与前端editForm一致isTimeBasedCard计算逻辑:cardType.type === 'TIMES' || cardType.type === 'TRIAL'detailMembership在openDetail时懒加载,不阻塞弹窗打开deleteUserMembership是软删除(status → EXPIRED),不是物理删除switchTab('edit')时 initEditForm 使用detailMembership.value(可能为 null)- 卡种 picker 变化时,
remainingTimes只对 TIMES/TRIAL 类型生效