Compare commits

..

11 Commits

Author SHA1 Message Date
richarjiang
23bdd05811 feat: 支持会员卡设置 2026-04-07 16:47:56 +08:00
richarjiang
91abedcb86 fix(app): prevent modal disappearing when switching to edit tab
Root cause: there was a stale loading overlay outside the tab content
(added by a previous subagent) that rendered when editLoading was true,
covering the entire modal body. Also loadDetailMembership incorrectly
managed editLoading causing state inconsistencies.

Fix:
- Remove the orphaned loading overlay outside tab content blocks
- loadDetailMembership no longer touches editLoading (switchTab owns it)
- switchTab uses Promise.all for parallel loading with formReady guard
- openDetail resets editLoading/formReady to clean state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:22:02 +08:00
richarjiang
0a20aef678 fix(app): ensure edit form initializes after async data loads
- switchTab('edit') now awaits fetchCardTypes and loadDetailMembership
  before calling initEditForm, fixing blank form fields for new cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:13:20 +08:00
richarjiang
e6056bcab1 fix(server): add definite assignment assertions to UpdateUserMembershipDto
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:53:14 +08:00
richarjiang
f14da2c538 feat(app): add membership edit modal with tab UI
- Replace detail-only modal with tab-based (detail/edit) modal
- Detail tab shows membership card info, stats, and action buttons
- Edit tab provides form to modify card type, remaining times, start/expire dates
- Add loadDetailMembership, switchTab, initEditForm, onSaveMembership, onClearMembership functions
- Add computed isTimeBasedCard for edit form conditional rendering
- Append new styles for modal-header, tab-bar, membership-card, edit-form, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:51:10 +08:00
richarjiang
b281990808 feat(app): add membership management actions to admin store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:44:41 +08:00
richarjiang
cea78aa8d0 feat(server): add admin membership management endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:43:45 +08:00
richarjiang
0776bd8630 feat(server): add membership CRUD methods to UserService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:42:42 +08:00
richarjiang
867461f892 feat(server): add UpdateUserMembershipDto for admin member card management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:41:16 +08:00
richarjiang
13b75c3bed docs: 补充会员卡编辑功能实现计划
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:39:43 +08:00
richarjiang
f7f18f5178 docs: 添加会员卡编辑功能设计文档
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:35:28 +08:00
12 changed files with 2279 additions and 147 deletions

View File

@@ -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)` — 创建或更新 membershipstatus 自动计算
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<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` 区块后添加新接口:
```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<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 模板改动
**原有弹窗代码:**
```vue
<!-- Detail modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
<view class="modal">
<!-- 详情内容 -->
</view>
</view>
```
**替换为:**
```vue
<!-- 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'`
```typescript
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>` 末尾添加以下样式:
```scss
/* ── 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 类型生效

View File

@@ -0,0 +1,186 @@
# 会员卡编辑功能设计
**日期:** 2026-04-07
**状态:** 已批准
---
## 1. 概述
在管理后台会员列表中,点击会员 item 时弹出的详情弹窗增加"编辑"能力。管理员可编辑指定用户的卡种、卡有效期、剩余次数,也可清空/解除会员卡。
**前置约束:**
- 一个用户只能拥有一张会员卡
- 支持清空卡(解除会员资格)
---
## 2. 交互设计
### 2.1 弹窗结构
```
┌─────────────────────────────────┐
│ 会员详情 [×] │
├──────────┬──────────────────────┤
│ 详情 │ 编辑 │ ← Tab 切换
├──────────┴──────────────────────┤
│ 头像 / 昵称 / OpenID / 手机号 │
│ ───────────────────────────── │
│ 会员卡信息区(根据 Tab 显示) │
│ 预约统计(总/完成/取消) │
│ ───────────────────────────── │
│ [关闭] │
└─────────────────────────────────┘
```
### 2.2 无卡用户交互
- 详情 Tab 显示"暂无会员卡"提示
- 显示"去开卡"按钮,点击切换到编辑 Tab
- 编辑 Tab 中卡种 picker 默认选中列表第一项,有效期自动计算
### 2.3 状态说明
| 场景 | Tab 初始显示 | 编辑表单状态 |
|------|-------------|-------------|
| 有卡用户 | 详情 Tab | 预填充当前数据 |
| 无卡用户 | 详情 Tab提示无卡 | 空白表单,卡种默认选中第一项 |
---
## 3. 表单字段
| 字段 | 组件 | 条件 | 说明 |
|------|------|------|------|
| 卡种 | picker | 必填 | 从 `CardType` 表读取,按 sortOrder 排序 |
| 剩余次数 | input (number) | TIMES / TRIAL 类型 | 默认填充卡种的 totalTimes |
| 生效日期 | date picker | 必填 | 默认当天 |
| 有效期至 | date picker | 必填 | 自动计算,支持手动调整 |
### 3.1 卡种切换逻辑
切换卡种时:
1. `remainingTimes` 自动填充新卡种的 `totalTimes`TIMES/TRIAL 类型)
2. `expireDate` 按新卡种 `durationDays` 重新计算起始日期
3. 编辑器内手动修改过 `expireDate` 时,不再自动覆盖
---
## 4. API 设计
### 4.1 获取用户会员卡
```
GET /admin/members/:userId/membership
```
**响应:**
```json
{
"userId": "uuid",
"membership": {
"id": "uuid",
"cardTypeId": "uuid",
"remainingTimes": 10,
"startDate": "2026-01-01",
"expireDate": "2026-04-01",
"status": "ACTIVE",
"cardType": { ... }
} | null
}
```
### 4.2 创建或更新会员卡
```
PUT /admin/members/:userId/membership
```
**请求体:**
```json
{
"cardTypeId": "uuid",
"remainingTimes": 10,
"startDate": "2026-04-07",
"expireDate": "2026-07-07"
}
```
**业务逻辑:**
- 若该用户已有 membership → 更新
- 若该用户无 membership → 创建新的
- `status` 由后端根据 expireDate 和 remainingTimes 自动计算
### 4.3 解除会员卡
```
DELETE /admin/members/:userId/membership
```
**业务逻辑:**
- 将 membership 的 `status` 标记为 `EXPIRED`(软删除,便于审计)
- 不物理删除记录
---
## 5. 数据模型
### 5.1 会员卡状态自动计算规则
```
if (expireDate < now) → EXPIRED
else if (remainingTimes === 0) → USED_UP
else → ACTIVE
```
### 5.2 变更日志
通过现有的 `BookingStatusHistory` 表记录操作tbd: 是否需要独立 `MembershipChangeLog` 表,待实现时确认)。
---
## 6. 错误处理
| 场景 | 处理 |
|------|------|
| 卡种不存在 | 后端返回 404前端提示"卡种不存在" |
| 有效期早于生效日期 | 前端表单校验失败,提示"有效期不能早于生效日期" |
| 次数为负数 | 前端表单校验失败,提示"次数不能为负数" |
| 网络错误 | Toast 提示"网络错误,请重试" |
| 清空卡确认 | 弹出确认对话框,提示"确定要解除该用户的会员卡吗?" |
---
## 7. 涉及改动
### 后端
- `packages/server/src/membership/membership.controller.ts` — 新增三个 admin 接口
- `packages/server/src/membership/membership.service.ts` — 新增 `getUserMembership` / `updateUserMembership` / `deleteUserMembership`
- `packages/server/src/user/user.controller.ts` — 无改动(列表接口保持不变)
### 前端
- `packages/app/src/stores/admin.ts` — 新增三个 store action
- `packages/app/src/pages/admin/members.vue` — 将详情弹窗改造为 Tab 模式,新增编辑表单
---
## 8. UI 细节
### 8.1 Tab 切换
- 详情/编辑 Tab 横向排列,激活态有下划线指示
- 无卡用户点击"去开卡"后,自动切到编辑 Tab
### 8.2 编辑表单提交
- 保存成功后 Toast 提示"保存成功"
- 自动切回详情 Tab 并刷新数据
- 保存按钮点击后置灰,防止重复提交
### 8.3 清空卡
- 需二次确认
- 成功后弹窗关闭,列表自动刷新

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@
<view class="card-header-left"> <view class="card-header-left">
<text class="card-name">{{ m.cardType.name }}</text> <text class="card-name">{{ m.cardType.name }}</text>
<view class="card-type-badge"> <view class="card-type-badge">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text> <text class="card-type-badge-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view> </view>
</view> </view>
<view class="status-badge status-badge--active"> <view class="status-badge status-badge--active">
@@ -70,11 +70,11 @@
<view class="progress-bar"> <view class="progress-bar">
<view <view
class="progress-fill" class="progress-fill"
:style="{ width: progressWidth(m) }" :style="{ width: getMembershipProgressWidth(m) }"
/> />
</view> </view>
<text class="progress-label"> <text class="progress-label">
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }} 已使用 {{ getMembershipUsedTimes(m) }} / {{ m.cardType.totalTimes }}
</text> </text>
</view> </view>
</template> </template>
@@ -110,7 +110,7 @@
<view class="card-header-left"> <view class="card-header-left">
<text class="card-name card-name--dim">{{ m.cardType.name }}</text> <text class="card-name card-name--dim">{{ m.cardType.name }}</text>
<view class="card-type-badge card-type-badge--dim"> <view class="card-type-badge card-type-badge--dim">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text> <text class="card-type-badge-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view> </view>
</view> </view>
<view class="status-badge" :class="statusBadgeClass(m.status)"> <view class="status-badge" :class="statusBadgeClass(m.status)">
@@ -148,6 +148,7 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared' import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore() const userStore = useUserStore()
@@ -170,14 +171,6 @@ const inactiveMemberships = computed(() =>
) )
// ─── Helpers ────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────
function typeLabel(type: CardTypeCategory): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验卡',
}
return map[type] ?? '会员卡'
}
function statusLabel(status: MembershipStatus): string { function statusLabel(status: MembershipStatus): string {
const map: Record<MembershipStatus, string> = { const map: Record<MembershipStatus, string> = {
@@ -206,17 +199,6 @@ function headerClass(type: CardTypeCategory): string {
return 'card-header--times' return 'card-header--times'
} }
function progressWidth(m: MembershipWithCardType): string {
if (m.remainingTimes === null || !m.cardType.totalTimes) return '0%'
const pct = (m.remainingTimes / m.cardType.totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
function usedTimes(m: MembershipWithCardType): number {
if (m.remainingTimes === null || !m.cardType.totalTimes) return 0
return m.cardType.totalTimes - m.remainingTimes
}
// ─── Data loading ───────────────────────────────────────── // ─── Data loading ─────────────────────────────────────────
async function loadMemberships() { async function loadMemberships() {
loading.value = true loading.value = true

View File

@@ -34,6 +34,25 @@ export interface MemberSummary {
cancelledBookings: number cancelledBookings: number
} }
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
}
export const useAdminStore = defineStore('admin', () => { export const useAdminStore = defineStore('admin', () => {
// ── Week templates ─────────────────────────────────────────────── // ── Week templates ───────────────────────────────────────────────
const weekTemplates = ref<WeekTemplate[]>([]) const weekTemplates = ref<WeekTemplate[]>([])
@@ -125,6 +144,26 @@ export const useAdminStore = defineStore('admin', () => {
return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams) return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams)
} }
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`)
}
// ── Time slots ─────────────────────────────────────────────────── // ── Time slots ───────────────────────────────────────────────────
async function fetchSlotsByDate(date: string): Promise<TimeSlot[]> { async function fetchSlotsByDate(date: string): Promise<TimeSlot[]> {
return get<TimeSlot[]>('/admin/time-slots', { date }) return get<TimeSlot[]>('/admin/time-slots', { date })
@@ -191,6 +230,9 @@ export const useAdminStore = defineStore('admin', () => {
fetchAdminBookings, fetchAdminBookings,
// Members // Members
fetchMembers, fetchMembers,
getUserMembership,
updateUserMembership,
deleteUserMembership,
// Time slots // Time slots
fetchSlotsByDate, fetchSlotsByDate,
createManualSlot, createManualSlot,

View File

@@ -1,6 +1,11 @@
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared' import { CardTypeCategory } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
readonly remainingTimes: number | null
readonly cardType: { readonly totalTimes: number | null }
}
/** 格式化金额:分 → 元 */ /** 格式化金额:分 → 元 */
export function formatPrice(cents: number): string { export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2) return (cents / 100).toFixed(2)
@@ -49,13 +54,13 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
} }
/** 会员卡类型标签 */ /** 会员卡类型标签 */
export function getCardTypeLabel(type: CardTypeCategory): string { export function getCardTypeLabel(type: CardTypeCategory | string): string {
const map: Record<CardTypeCategory, string> = { const map: Record<string, string> = {
[CardTypeCategory.TIMES]: '次卡', [CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡', [CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验', [CardTypeCategory.TRIAL]: '体验',
} }
return map[type] ?? '会员' return map[type] ?? '会员'
} }
/** 会员卡封面 CSS 类名 */ /** 会员卡封面 CSS 类名 */
@@ -70,3 +75,23 @@ export function isSlotPast(date: string, startTime: string): boolean {
const slotDateTime = new Date(`${date}T${startTime}:00`) const slotDateTime = new Date(`${date}T${startTime}:00`)
return new Date() > slotDateTime return new Date() > slotDateTime
} }
/** 会员卡渐变 CSS 类名前缀 */
export function getCardGradientClass(type: CardTypeCategory | string): string {
if (type === CardTypeCategory.DURATION) return 'gradient--duration'
if (type === CardTypeCategory.TRIAL) return 'gradient--trial'
return 'gradient--times'
}
/** 会员卡进度百分比(剩余 / 总次数) */
export function getMembershipProgressWidth(membership: MembershipLike): string {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
/** 已使用次数 */
export function getMembershipUsedTimes(membership: MembershipLike): number {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
return membership.cardType.totalTimes - membership.remainingTimes
}

View File

@@ -8,6 +8,7 @@ import {
} from '@nestjs/common' } from '@nestjs/common'
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import type { ApiResponse } from '@mp-pilates/shared' import type { ApiResponse } from '@mp-pilates/shared'
import { formatRequestExtras } from '../utils/request-log'
@Catch() @Catch()
export class ApiExceptionFilter implements ExceptionFilter { export class ApiExceptionFilter implements ExceptionFilter {
@@ -28,15 +29,16 @@ export class ApiExceptionFilter implements ExceptionFilter {
? this.extractMessage(exception) ? this.extractMessage(exception)
: '服务器内部错误' : '服务器内部错误'
// Log all server errors (5xx) with full stack; log 4xx at warn level const extras = formatRequestExtras(request)
if (status >= 500) { if (status >= 500) {
this.logger.error( this.logger.error(
`${request.method} ${request.originalUrl}${String(status)} ${message}`, `${request.method} ${request.originalUrl}${String(status)} ${message}${extras}`,
exception instanceof Error ? exception.stack : undefined, exception instanceof Error ? exception.stack : undefined,
) )
} else if (status >= 400) { } else if (status >= 400) {
this.logger.warn( this.logger.warn(
`${request.method} ${request.originalUrl}${String(status)} ${message}`, `${request.method} ${request.originalUrl}${String(status)} ${message}${extras}`,
) )
} }

View File

@@ -7,28 +7,7 @@ import {
} from '@nestjs/common' } from '@nestjs/common'
import { Observable, tap } from 'rxjs' import { Observable, tap } from 'rxjs'
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { formatRequestExtras } from '../utils/request-log'
/** Fields stripped from logged request bodies to avoid leaking secrets. */
const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'password',
'token',
'secret',
'code',
'sessionKey',
'encryptedData',
'iv',
])
function sanitizeBody(
body: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!body || typeof body !== 'object') return undefined
return Object.fromEntries(
Object.entries(body).map(([key, value]) =>
SENSITIVE_FIELDS.has(key) ? [key, '***'] : [key, value],
),
)
}
@Injectable() @Injectable()
export class LoggingInterceptor implements NestInterceptor { export class LoggingInterceptor implements NestInterceptor {
@@ -44,9 +23,9 @@ export class LoggingInterceptor implements NestInterceptor {
next: () => { next: () => {
const res = context.switchToHttp().getResponse<Response>() const res = context.switchToHttp().getResponse<Response>()
const duration = Date.now() - start const duration = Date.now() - start
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>) const extras = formatRequestExtras(req)
this.logger.log( this.logger.log(
`${method} ${originalUrl}${String(res.statusCode)} (${String(duration)}ms)${bodyLog}`, `${method} ${originalUrl}${String(res.statusCode)} (${String(duration)}ms)${extras}`,
) )
}, },
error: (err: unknown) => { error: (err: unknown) => {
@@ -55,22 +34,12 @@ export class LoggingInterceptor implements NestInterceptor {
err instanceof Object && 'getStatus' in err err instanceof Object && 'getStatus' in err
? String((err as { getStatus: () => number }).getStatus()) ? String((err as { getStatus: () => number }).getStatus())
: '500' : '500'
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>) const extras = formatRequestExtras(req)
this.logger.error( this.logger.error(
`${method} ${originalUrl}${status} (${String(duration)}ms)${bodyLog}`, `${method} ${originalUrl}${status} (${String(duration)}ms)${extras}`,
) )
}, },
}), }),
) )
} }
private formatBody(
method: string,
body: Record<string, unknown> | undefined,
): string {
if (!['POST', 'PUT', 'PATCH'].includes(method)) return ''
const sanitized = sanitizeBody(body)
if (!sanitized || Object.keys(sanitized).length === 0) return ''
return ` body=${JSON.stringify(sanitized)}`
}
} }

View File

@@ -0,0 +1,60 @@
import type { Request } from 'express'
/** Fields stripped from logged request bodies to avoid leaking secrets. */
const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'password',
'token',
'secret',
'code',
'sessionKey',
'encryptedData',
'iv',
])
const BODY_METHODS: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH'])
/** Max characters of JSON-serialised body/query included in a log line. */
const MAX_LOG_PAYLOAD = 2048
function truncate(value: string): string {
return value.length > MAX_LOG_PAYLOAD
? `${value.slice(0, MAX_LOG_PAYLOAD)}…(truncated)`
: value
}
export function sanitizeBody(
body: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!body || typeof body !== 'object') return undefined
const keys = Object.keys(body)
if (keys.length === 0) return undefined
const result: Record<string, unknown> = {}
for (const key of keys) {
result[key] = SENSITIVE_FIELDS.has(key) ? '***' : body[key]
}
return result
}
/**
* Build a human-readable suffix for a log line:
* ` | query={…} body={…}`
* Returns an empty string when there is nothing to append.
*/
export function formatRequestExtras(request: Request): string {
const parts: string[] = []
const query = request.query
if (query && Object.keys(query).length > 0) {
parts.push(`query=${truncate(JSON.stringify(query))}`)
}
if (BODY_METHODS.has(request.method)) {
const sanitized = sanitizeBody(request.body as Record<string, unknown>)
if (sanitized) {
parts.push(`body=${truncate(JSON.stringify(sanitized))}`)
}
}
return parts.length > 0 ? ` | ${parts.join(' ')}` : ''
}

View File

@@ -0,0 +1,19 @@
import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
import { Type } from 'class-transformer'
export class UpdateUserMembershipDto {
@IsUUID()
cardTypeId!: string
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
remainingTimes?: number | null
@IsDateString()
startDate!: string
@IsDateString()
expireDate!: string
}

View File

@@ -2,7 +2,9 @@ import {
Controller, Controller,
Get, Get,
Put, Put,
Delete,
Body, Body,
Param,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common' } from '@nestjs/common'
@@ -13,6 +15,7 @@ import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator' 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'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory)) const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
@@ -61,4 +64,28 @@ export class UserController {
validCardType, validCardType,
) )
} }
@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)
}
} }

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'
import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } 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'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory)) const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
@@ -226,4 +227,58 @@ export class UserService {
return { items, total, page, limit } return { items, total, page, limit }
} }
// ─── Membership management ────────────────────────────────────────────────
async getUserMembership(userId: string) {
const membership = await this.prisma.membership.findFirst({
where: { userId },
include: { cardType: true },
})
return { membership }
}
async updateUserMembership(userId: string, dto: UpdateUserMembershipDto) {
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
}
const data = {
cardTypeId: dto.cardTypeId,
remainingTimes: dto.remainingTimes ?? null,
startDate: new Date(dto.startDate),
expireDate: new Date(dto.expireDate),
status,
}
const existing = await this.prisma.membership.findFirst({ where: { userId } })
if (existing) {
return this.prisma.membership.update({
where: { id: existing.id },
data,
include: { cardType: true },
})
}
return this.prisma.membership.create({
data: { userId, ...data },
include: { cardType: true },
})
}
async deleteUserMembership(userId: string): Promise<void> {
await this.prisma.membership.updateMany({
where: { userId },
data: { status: MembershipStatus.EXPIRED },
})
}
} }