Files
mp-pilates/docs/superpowers/plans/2026-04-07-member-card-edit.md
richarjiang 13b75c3bed docs: 补充会员卡编辑功能实现计划
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:39:43 +08:00

25 KiB
Raw Blame History

会员卡编辑功能实现计划

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 个新方法

需要添加的方法:

  1. getUserMembership(userId: string) — 返回 Membership & { cardType: CardType } | null
  2. updateUserMembership(userId: string, dto: UpdateUserMembershipDto) — 创建或更新 membershipstatus 自动计算
  3. deleteUserMembership(userId: string) — 软删除status → EXPIRED

关键业务逻辑:

  • updateUserMembership 中 status 计算规则:
    • expireDate < nowEXPIRED
    • remainingTimes === 0USED_UP
    • 否则 → ACTIVE
  • 如果用户已有 membership → 更新;无 → 创建新的
  • 需要同时 import CardTypeCategoryMembershipStatus
// 在 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'
  • detailMembershipopenDetail 时懒加载,不阻塞弹窗打开
  • deleteUserMembership 是软删除status → EXPIRED不是物理删除
  • switchTab('edit') 时 initEditForm 使用 detailMembership.value(可能为 null
  • 卡种 picker 变化时,remainingTimes 只对 TIMES/TRIAL 类型生效