# 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) ```prisma 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) ```prisma 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) ```typescript export enum CardTypeCategory { TIMES = 'TIMES', DURATION = 'DURATION', TRIAL = 'TRIAL', } ``` --- ## 2. SHARED TYPES & DTOs ### CardType Interface **File**: `packages/shared/src/types/card-type.ts` ```typescript 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 ```typescript 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 ```typescript 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**: ```typescript // 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: ```typescript const cardTypes = ref([]) const loading = ref(false) const showModal = ref(false) const submitting = ref(false) const editTarget = ref(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` ```typescript export const useAdminStore = defineStore('admin', () => { // ─── Card types ─────────────────── const cardTypes = ref([]) async function fetchCardTypes(): Promise { const data = await get('/admin/card-types') cardTypes.value = [...data].sort((a, b) => a.sortOrder - b.sortOrder) return cardTypes.value } async function createCardType(dto: CreateCardTypeDto): Promise { const data = await post('/admin/card-types', dto) await fetchCardTypes() // Refetch to get updated list return data } async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise { const data = await put(`/admin/card-types/${id}`, dto) await fetchCardTypes() // Refetch to get updated list return data } async function deleteCardType(id: string): Promise { 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` ```typescript const BASE_URL = 'http://localhost:3000/api' // or production URL // Helper functions async function get(url: string, data?: Record): Promise async function post(url: string, data?: Record): Promise async function put(url: string, data?: Record): Promise async function del(url: string, data?: Record): Promise ``` **Response Format**: ```typescript interface ApiResponse { 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**: ```typescript 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): ```vue ``` **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 ```vue ``` 2. **Add delay for modal rendering**: Let Vue finish the current cycle ```typescript 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 ```typescript 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)