16 KiB
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: trueby default totalTimesanddescriptionare optional (default to null)
updateCardType(id: string, dto: UpdateCardTypeDto)
- Updates card (all fields optional)
- Can toggle
isActivefor 上架/下架 - Can update name, price, duration, etc.
deleteCardType(id: string)
- Soft delete: doesn't remove from DB
- Sets
isActive: falseinstead - Updates the record
4. FRONTEND ADMIN PAGE
Card-Types Page
File: packages/app/src/pages/admin/card-types.vue
Layout Structure:
-
Toolbar (top)
- Shows count: "共 X 个卡种"
- "+ 新增卡种" button →
openAdd()
-
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: 编辑, 下架/上架, 删除
- Each card shows:
-
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
cardTypesref
openAdd()
- Sets
editTarget = null - Resets
formto initial state - Sets
showModal = true - → Opens new card form
openEdit(ct: CardType)
- Sets
editTarget = ct - Populates
formfrom card data - Finds
typeIdxfrom 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
editTargetexists: callsadminStore.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
- User taps "+ 新增卡种" button
openAdd()is callededitTarget = nullformreset to defaultsshowModal = true
- Modal appears with empty form
- User fills in form fields
- User taps "确认" button
submitForm()validates, builds payload, callsadminStore.createCardType(payload)- Backend creates new CardType (with
isActive: trueby default) - Admin store refetches list
- Page updates with new card
- Modal closes automatically
Editing a Card Type
- User taps "编辑" button on a card
openEdit(ct)is callededitTarget = ctformpopulated from card datashowModal = true
- Modal appears with prefilled form
- User modifies fields
- User taps "确认" button
submitForm()validates, builds payload, callsadminStore.updateCardType(id, payload)- Backend updates CardType
- Admin store refetches list
- Page updates with new data
- Modal closes automatically
Toggling Active Status (上架/下架)
- User taps "下架" or "上架" button
toggleActive(ct)is called- Calls
adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
- Calls
- Backend updates
isActivefield - Admin store refetches list
- Page re-renders:
- If
isActive: false: card becomes semi-transparent (opacity: 0.6) - Status tag changes from "销售中" to "已下架"
- Button text changes
- If
Deleting a Card Type
- User taps "删除" button
confirmDelete(ct)is called- Shows confirmation dialog
- User confirms deletion
adminStore.deleteCardType(ct.id)called- Backend does soft delete: sets
isActive: false - Admin store refetches list
- 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
98000cents - 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 (
#1a1a2eto#2d2d5e)
DURATION Card (月卡)
- Used for time-period-based purchases
- Example: "30天卡"
- Required fields:
durationDays totalTimesis optional/not used- Color: Purple gradient (
#6c3483to#9b59b6)
TRIAL Card (体验卡)
- Used for trial/sample purchases
- Color: Gold/tan gradient (
#7d6608to#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:
- User taps "编辑" button on a card (line 67)
openEdit(ct)setsshowModal = true- Modal appears
- BUT: The tap event likely bubbles or there's a race condition
- The click that triggered
openEdit()might also triggercloseModal()
Potential Causes:
-
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
-
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
-
Vue/Uni-app Quirk:
- Some mini-program frameworks have event timing issues
- The
.selfmodifier might not work as expected with rapid re-renders
Solution Approaches:
-
Add click guard: Prevent tap on edit button from propagating
<view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)"> -
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 }) } -
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 } } -
Restructure modal trigger:
- Separate the button from the modal in the DOM
- Or use a completely different event model
SUMMARY OF ALL FILES REVIEWED
- ✅ Frontend page:
packages/app/src/pages/admin/card-types.vue(607 lines) - ✅ Admin store:
packages/app/src/stores/admin.ts(198 lines) - ✅ Shared types:
packages/shared/src/types/card-type.ts(39 lines) - ✅ Server controller:
packages/server/src/membership/membership.controller.ts(68 lines) - ✅ Server service:
packages/server/src/membership/membership.service.ts(173 lines) - ✅ Create DTO:
packages/server/src/membership/dto/create-card-type.dto.ts(45 lines) - ✅ Update DTO:
packages/server/src/membership/dto/update-card-type.dto.ts(49 lines) - ✅ Prisma schema:
packages/server/prisma/schema.prisma(205 lines) - ✅ Shared enums:
packages/shared/src/enums.ts(47 lines) - ✅ Format utils:
packages/app/src/utils/format.ts(46 lines) - ✅ Request utils:
packages/app/src/utils/request.ts(80 lines) - ✅ Membership types:
packages/shared/src/types/membership.ts(19 lines) - ✅ API types:
packages/shared/src/types/api.ts(20 lines)