Files
mp-pilates/CARD_TYPES_ANALYSIS.md
2026-04-05 13:25:54 +08:00

16 KiB
Raw Blame History

Card Types Management Feature - Comprehensive Analysis

Project Structure

  • Frontend: packages/app (Vue 3 + Uni-app mini-program)
  • Backend: packages/server (NestJS)
  • Shared: packages/shared (types, enums, DTOs)

1. DATABASE SCHEMA (Prisma)

CardType Model

File: packages/server/prisma/schema.prisma (lines 73-91)

model CardType {
  id            String           @id @default(uuid())
  name          String
  type          CardTypeCategory  // TIMES | DURATION | TRIAL
  totalTimes    Int?              // For TIMES/TRIAL cards
  durationDays  Int               // How many days card is valid
  price         Decimal(10, 0)    // Current price (in cents internally)
  originalPrice Decimal?(10, 0)   // Optional strikethrough price
  description   String?
  isActive      Boolean @default(true)  // For 上架/下架
  sortOrder     Int @default(0)   // Display order
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  memberships   Membership[]
  orders        Order[]
}

Card Type Category Enum

File: packages/server/prisma/schema.prisma (lines 17-21)

enum CardTypeCategory {
  TIMES      // Time-based card (e.g., 10 classes)
  DURATION   // Month card (e.g., 30 days)
  TRIAL      // Trial card
}

Shared Enum: packages/shared/src/enums.ts (lines 8-12)

export enum CardTypeCategory {
  TIMES = 'TIMES',
  DURATION = 'DURATION',
  TRIAL = 'TRIAL',
}

2. SHARED TYPES & DTOs

CardType Interface

File: packages/shared/src/types/card-type.ts

export interface CardType {
  readonly id: string
  readonly name: string
  readonly type: CardTypeCategory        // TIMES | DURATION | TRIAL
  readonly totalTimes: number | null     // null for DURATION cards
  readonly durationDays: number
  readonly price: number                 // In cents, e.g., 98000 = ¥980
  readonly originalPrice: number | null
  readonly description: string | null
  readonly isActive: boolean             // true = 销售中, false = 已下架
  readonly sortOrder: number
  readonly createdAt: string
  readonly updatedAt: string
}

CreateCardTypeDto

export interface CreateCardTypeDto {
  readonly name: string
  readonly type: CardTypeCategory
  readonly totalTimes?: number
  readonly durationDays: number
  readonly price: number
  readonly originalPrice?: number
  readonly description?: string
  readonly sortOrder?: number
}

UpdateCardTypeDto

export interface UpdateCardTypeDto {
  readonly name?: string
  readonly totalTimes?: number
  readonly durationDays?: number
  readonly price?: number
  readonly originalPrice?: number
  readonly description?: string
  readonly isActive?: boolean            // For toggling 上架/下架
  readonly sortOrder?: number
}

3. SERVER-SIDE IMPLEMENTATION

Membership Controller

File: packages/server/src/membership/membership.controller.ts

Endpoints:

// Public (no auth)
GET    /membership/card-types               getActiveCardTypes()

// Admin only (JWT + RolesGuard)
GET    /admin/card-types                    getAllCardTypes()
POST   /admin/card-types                    createCardType(dto)
PUT    /admin/card-types/:id                updateCardType(id, dto)
DELETE /admin/card-types/:id                deleteCardType(id)

Membership Service

File: packages/server/src/membership/membership.service.ts

getActiveCardTypes()

  • Returns only cards where isActive: true
  • Sorted by sortOrder (ascending)
  • Used by regular users/public

getAllCardTypes()

  • Returns all cards (including inactive)
  • Sorted by sortOrder
  • Admin-only

createCardType(dto: CreateCardTypeDto)

  • Creates a new card type
  • Sets isActive: true by default
  • totalTimes and description are optional (default to null)

updateCardType(id: string, dto: UpdateCardTypeDto)

  • Updates card (all fields optional)
  • Can toggle isActive for 上架/下架
  • Can update name, price, duration, etc.

deleteCardType(id: string)

  • Soft delete: doesn't remove from DB
  • Sets isActive: false instead
  • Updates the record

4. FRONTEND ADMIN PAGE

Card-Types Page

File: packages/app/src/pages/admin/card-types.vue

Layout Structure:

  1. Toolbar (top)

    • Shows count: "共 X 个卡种"
    • "+ 新增卡种" button → openAdd()
  2. Card List (scrollable)

    • Each card shows:
      • Header band (colored by type: 次卡/月卡/体验卡)
      • Status tag (销售中 or 已下架)
      • Card name, price, description
      • Meta info: times, duration, sort order
      • Three action buttons: 编辑, 下架/上架, 删除
  3. Modal/Popup (add/edit form)

    • Title: "新增卡种" or "编辑卡种"
    • Input fields:
      • 卡种名称 (name)
      • 类型 (picker: 次卡, 月卡, 体验卡)
      • 现价 (price, digit input)
      • 原价 (originalPrice, optional)
      • 次数 (totalTimes, optional, required for 次卡)
      • 有效天数 (durationDays, required)
      • 排序值 (sortOrder, defaults to 0)
      • 描述 (description, optional textarea)
    • Cancel and Confirm buttons

Key Ref Variables:

const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const editTarget = ref<CardType | null>(null)

const form = ref({
  name: '',
  typeIdx: 0,              // Index into typeOptions
  priceStr: '',            // String, parsed to number
  originalPriceStr: '',
  totalTimesStr: '',
  durationDaysStr: '90',   // Default 90 days
  sortOrderStr: '0',       // Default 0
  description: '',
})

Functions:

fetchCardTypes()

  • Calls adminStore.fetchCardTypes()
  • Sets loading state
  • Updates cardTypes ref

openAdd()

  • Sets editTarget = null
  • Resets form to initial state
  • Sets showModal = true
  • Opens new card form

openEdit(ct: CardType)

  • Sets editTarget = ct
  • Populates form from card data
  • Finds typeIdx from typeOptions
  • Sets showModal = true
  • Opens edit form with card data

closeModal()

  • Sets showModal = false
  • Clears editTarget

submitForm()

  • Validates: name (required), price (required, > 0), durationDays (required, >= 1)
  • Parses string inputs to numbers
  • Builds payload object
  • If editTarget exists: calls adminStore.updateCardType()
  • Else: calls adminStore.createCardType()
  • Shows success toast and refetches list
  • Catches errors and shows error toast

toggleActive(ct: CardType)

  • Calls adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
  • Refetches list
  • 上架/下架 button action

confirmDelete(ct: CardType)

  • Shows confirmation modal: "删除卡种「X」此操作不可恢复。"
  • If confirmed: calls adminStore.deleteCardType(ct.id)
  • Soft deletes (sets isActive: false)
  • Shows success toast
  • Refetches list

Helper Functions:

typeLabel(ct: CardType): string

  • Maps enum to Chinese: TIMES → '次卡', DURATION → '月卡', TRIAL → '体验卡'

headerClass(ct: CardType): string

  • Returns CSS class for colored header banner

5. ADMIN STORE (Pinia)

File: packages/app/src/stores/admin.ts

export const useAdminStore = defineStore('admin', () => {
  // ─── Card types ───────────────────
  const cardTypes = ref<CardType[]>([])

  async function fetchCardTypes(): Promise<CardType[]> {
    const data = await get<CardType[]>('/admin/card-types')
    cardTypes.value = [...data].sort((a, b) => a.sortOrder - b.sortOrder)
    return cardTypes.value
  }

  async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
    const data = await post<CardType>('/admin/card-types', dto)
    await fetchCardTypes()  // Refetch to get updated list
    return data
  }

  async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
    const data = await put<CardType>(`/admin/card-types/${id}`, dto)
    await fetchCardTypes()  // Refetch to get updated list
    return data
  }

  async function deleteCardType(id: string): Promise<void> {
    await del(`/admin/card-types/${id}`)
    await fetchCardTypes()  // Refetch to get updated list
  }

  return {
    cardTypes,
    fetchCardTypes,
    createCardType,
    updateCardType,
    deleteCardType,
    // ... other admin functions
  }
})

6. WORKFLOW FLOWS

Adding a New Card Type

  1. User taps "+ 新增卡种" button
  2. openAdd() is called
    • editTarget = null
    • form reset to defaults
    • showModal = true
  3. Modal appears with empty form
  4. User fills in form fields
  5. User taps "确认" button
  6. submitForm() validates, builds payload, calls adminStore.createCardType(payload)
  7. Backend creates new CardType (with isActive: true by default)
  8. Admin store refetches list
  9. Page updates with new card
  10. Modal closes automatically

Editing a Card Type

  1. User taps "编辑" button on a card
  2. openEdit(ct) is called
    • editTarget = ct
    • form populated from card data
    • showModal = true
  3. Modal appears with prefilled form
  4. User modifies fields
  5. User taps "确认" button
  6. submitForm() validates, builds payload, calls adminStore.updateCardType(id, payload)
  7. Backend updates CardType
  8. Admin store refetches list
  9. Page updates with new data
  10. Modal closes automatically

Toggling Active Status (上架/下架)

  1. User taps "下架" or "上架" button
  2. toggleActive(ct) is called
    • Calls adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
  3. Backend updates isActive field
  4. Admin store refetches list
  5. Page re-renders:
    • If isActive: false: card becomes semi-transparent (opacity: 0.6)
    • Status tag changes from "销售中" to "已下架"
    • Button text changes

Deleting a Card Type

  1. User taps "删除" button
  2. confirmDelete(ct) is called
    • Shows confirmation dialog
  3. User confirms deletion
  4. adminStore.deleteCardType(ct.id) called
  5. Backend does soft delete: sets isActive: false
  6. Admin store refetches list
  7. Page updates (card marked as inactive)

7. API COMMUNICATION

Request Utility

File: packages/app/src/utils/request.ts

const BASE_URL = 'http://localhost:3000/api'  // or production URL

// Helper functions
async function get<T>(url: string, data?: Record<string, unknown>): Promise<T>
async function post<T>(url: string, data?: Record<string, unknown>): Promise<T>
async function put<T>(url: string, data?: Record<string, unknown>): Promise<T>
async function del<T>(url: string, data?: Record<string, unknown>): Promise<T>

Response Format:

interface ApiResponse<T> {
  success: boolean
  data: T | null
  message: string | null
}

All admin endpoints require:

  • JWT Bearer token (from storage)
  • User role must be ADMIN

8. PRICE HANDLING

Important: Prices are stored as integers (cents) in DB and API

  • ¥980 is stored as 98000 cents
  • Frontend displays formatted: ¥980.00

Formatting:

export function formatPrice(cents: number): string {
  return (cents / 100).toFixed(2)  // 98000 → "980.00"
}

In Page: ¥{{ formatPrice(ct.price) }}


9. CARD TYPE CATEGORIES

TIMES Card (次卡)

  • Used for class count-based purchases
  • Example: "10次课套餐"
  • Required fields: totalTimes (e.g., 10)
  • Optional fields: originalPrice, description
  • Color: Dark blue gradient (#1a1a2e to #2d2d5e)

DURATION Card (月卡)

  • Used for time-period-based purchases
  • Example: "30天卡"
  • Required fields: durationDays
  • totalTimes is optional/not used
  • Color: Purple gradient (#6c3483 to #9b59b6)

TRIAL Card (体验卡)

  • Used for trial/sample purchases
  • Color: Gold/tan gradient (#7d6608 to #c9a87c)

10. FIELD REQUIREMENTS & VALIDATION

Field Create Update Type Validation
name ✓ Required Optional string Trimmed, non-empty
type ✓ Required Optional enum TIMES | DURATION | TRIAL
totalTimes Optional Optional integer Min: 1
durationDays ✓ Required Optional integer Min: 1
price ✓ Required Optional number Min: 0
originalPrice Optional Optional number Min: 0
description Optional Optional string Max: 200 chars
sortOrder Optional Optional integer Min: 0, default: 0
isActive N/A Optional boolean default: true on create

11. POTENTIAL ISSUES & BUG: Edit Popup Closes Immediately

Issue Description

When user taps "编辑" button, the edit modal popup closes immediately instead of staying open.

Root Cause Analysis

Looking at the template structure (lines 85-195 of card-types.vue):

<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
  <scroll-view scroll-y class="modal">
    <!-- Form content -->
  </scroll-view>
</view>

The problem:

  1. User taps "编辑" button on a card (line 67)
  2. openEdit(ct) sets showModal = true
  3. Modal appears
  4. BUT: The tap event likely bubbles or there's a race condition
  5. The click that triggered openEdit() might also trigger closeModal()

Potential Causes:

  1. Event Propagation Issue:

    • The edit button tap might bubble to parent elements
    • The modal-mask has @tap.self="closeModal"
    • If the modal appears in the same frame, the tap event might close it
  2. Modal Rendering Timing:

    • If modal renders synchronously in the same event tick
    • The tap event (which hasn't finished propagating) might hit the modal-mask
  3. Vue/Uni-app Quirk:

    • Some mini-program frameworks have event timing issues
    • The .self modifier might not work as expected with rapid re-renders

Solution Approaches:

  1. Add click guard: Prevent tap on edit button from propagating

    <view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
    
  2. Add delay for modal rendering: Let Vue finish the current cycle

    function openEdit(ct: CardType) {
      editTarget.value = ct
      form.value = { ... }
      // Delay modal show to next tick
      nextTick(() => {
        showModal.value = true
      })
    }
    
  3. Track modal state change: Ignore tap events for a brief moment after modal opens

    const modalJustOpened = ref(false)
    
    function openEdit(ct: CardType) {
      editTarget.value = ct
      form.value = { ... }
      showModal.value = true
      modalJustOpened.value = true
      setTimeout(() => {
        modalJustOpened.value = false
      }, 100)
    }
    
    function closeModal() {
      if (!modalJustOpened.value) {
        showModal.value = false
        editTarget.value = null
      }
    }
    
  4. Restructure modal trigger:

    • Separate the button from the modal in the DOM
    • Or use a completely different event model

SUMMARY OF ALL FILES REVIEWED

  1. Frontend page: packages/app/src/pages/admin/card-types.vue (607 lines)
  2. Admin store: packages/app/src/stores/admin.ts (198 lines)
  3. Shared types: packages/shared/src/types/card-type.ts (39 lines)
  4. Server controller: packages/server/src/membership/membership.controller.ts (68 lines)
  5. Server service: packages/server/src/membership/membership.service.ts (173 lines)
  6. Create DTO: packages/server/src/membership/dto/create-card-type.dto.ts (45 lines)
  7. Update DTO: packages/server/src/membership/dto/update-card-type.dto.ts (49 lines)
  8. Prisma schema: packages/server/prisma/schema.prisma (205 lines)
  9. Shared enums: packages/shared/src/enums.ts (47 lines)
  10. Format utils: packages/app/src/utils/format.ts (46 lines)
  11. Request utils: packages/app/src/utils/request.ts (80 lines)
  12. Membership types: packages/shared/src/types/membership.ts (19 lines)
  13. API types: packages/shared/src/types/api.ts (20 lines)