perf: 优化页面
This commit is contained in:
548
CARD_TYPES_ANALYSIS.md
Normal file
548
CARD_TYPES_ANALYSIS.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# 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<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`
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
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**:
|
||||
```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
|
||||
<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
|
||||
```vue
|
||||
<view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user