feat(admin): implement full day-by-day schedule editor with live preview
## Features ### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`) - Interactive date-based slot editor for managing daily schedules - Real-time slot editing: start/end times, capacity adjustments - Slot deletion with conflict warnings when bookings exist - Add new slots with modal dialog - Live booking status display (booked count, people names) - Publish/Save changes with sync feedback - Revert unsaved changes with confirmation - Skeleton loading states and empty state handling - Responsive design with optimized mobile UX ### Backend Enhancements - **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation - Date string validation - Slot array with existing slot IDs for updates - Time and capacity validation per slot - **Schedule Preview API** (`getSchedulePreview`): - Check for existing published slots - Fallback to active WeekTemplates for unpublished dates - Unified response format with isPublished flag - **Publish Slots API** (`publishDaySlots`): - Atomic transaction for consistency - Update existing slots with new times/capacity - Create new slots from template data - Delete unpublished slots or set to CLOSED if bookings exist - Prevent capacity reduction below existing bookings - Returns all published slots for feedback ### State Management - Enhanced admin store with schedule state - Support for pending/unsaved slot changes - Optimistic UI updates with server sync ### Documentation - Comprehensive scheduling system architecture docs - Quick reference for admin workflows - Flow diagrams and state transitions - Implementation guide for future maintenance ## Breaking Changes None ## Testing Recommendations - Create slots for future dates via schedule editor - Verify booking prevention for locked/full slots - Test capacity adjustments with existing bookings - Confirm template-based schedule generation - Verify transaction rollback on publish failures Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
592
QUICK_REFERENCE.md
Normal file
592
QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# Booking Page - Quick Reference & Code Snippets
|
||||
|
||||
## 🚀 Quick Start: Understanding the Flow
|
||||
|
||||
### Where Slots Come From
|
||||
```typescript
|
||||
// 1. Store calls API
|
||||
packages/app/src/stores/booking.ts:17-27
|
||||
async function fetchSlots(date: string) {
|
||||
loadingSlots.value = true
|
||||
try {
|
||||
// GET /time-slot/available?date=2026-04-05
|
||||
slots.value = await get<TimeSlotWithBookingStatus[]>(
|
||||
'/time-slot/available',
|
||||
{ date }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Fetch slots failed:', err)
|
||||
slots.value = [] // ⚠️ Clears on error!
|
||||
} finally {
|
||||
loadingSlots.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Where Time Periods Are Defined
|
||||
```typescript
|
||||
// packages/shared/src/constants.ts:11-15
|
||||
export const TIME_PERIODS = {
|
||||
MORNING: { label: '上午', start: '06:00', end: '12:00' },
|
||||
AFTERNOON: { label: '下午', start: '12:00', end: '18:00' },
|
||||
EVENING: { label: '晚上', start: '18:00', end: '22:00' },
|
||||
} as const
|
||||
```
|
||||
|
||||
### Where Filtering Happens
|
||||
```typescript
|
||||
// pages/booking/index.vue:94-103
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||
if (!selectedPeriod.value) return [...slots]
|
||||
|
||||
const period = TIME_PERIODS[selectedPeriod.value]
|
||||
return slots.filter((slot) => {
|
||||
const t = slot.startTime // "09:00", "10:00", etc
|
||||
return t >= period.start && t < period.end
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Slot Rendering
|
||||
```vue
|
||||
<!-- pages/booking/index.vue:34-42 -->
|
||||
<view v-else class="slot-list">
|
||||
<SlotCard
|
||||
v-for="slot in filteredSlots"
|
||||
:key="slot.id"
|
||||
:slot="slot"
|
||||
@book="onBookTap"
|
||||
@cancel="onCancelTap"
|
||||
/>
|
||||
</view>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Finding Specific Things
|
||||
|
||||
### Q: Where do the time slot types come from?
|
||||
**A:** `packages/shared/src/types/time-slot.ts`
|
||||
```typescript
|
||||
interface TimeSlotWithBookingStatus extends TimeSlot {
|
||||
readonly isBookedByMe: boolean // true if user booked it
|
||||
readonly myBookingId: string | null // needed for cancellation
|
||||
}
|
||||
|
||||
interface TimeSlot {
|
||||
readonly id: string // UUID
|
||||
readonly date: string // "2026-04-05"
|
||||
readonly startTime: string // "09:00"
|
||||
readonly endTime: string // "10:00"
|
||||
readonly capacity: number // 1 (for private lessons)
|
||||
readonly bookedCount: number // 0 or 1
|
||||
readonly status: TimeSlotStatus // OPEN|FULL|CLOSED
|
||||
readonly source: TimeSlotSource // TEMPLATE|MANUAL
|
||||
readonly templateId: string | null
|
||||
readonly createdAt: string
|
||||
readonly updatedAt: string
|
||||
}
|
||||
```
|
||||
|
||||
### Q: Where is the membership selection happening?
|
||||
**A:** `components/BookingConfirmPopup.vue:136-147`
|
||||
```typescript
|
||||
const selectedMembershipId = ref<string>('')
|
||||
|
||||
watch(
|
||||
[() => props.visible, () => props.memberships],
|
||||
([visible, memberships]) => {
|
||||
if (visible && memberships.length > 0) {
|
||||
selectedMembershipId.value = memberships[0].id // Auto-select first
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
```
|
||||
|
||||
### Q: Where are the button states determined?
|
||||
**A:** `components/SlotCard.vue:15-45`
|
||||
```vue
|
||||
<!-- OPEN + not booked by me -->
|
||||
<template v-if="slot.status === TimeSlotStatus.OPEN && !slot.isBookedByMe">
|
||||
<view class="btn btn-book" @tap.stop="emit('book', slot)">
|
||||
<text class="btn-text">可预约</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- OPEN + booked by me -->
|
||||
<template v-else-if="slot.status === TimeSlotStatus.OPEN && slot.isBookedByMe">
|
||||
<view class="booked-row">
|
||||
<view class="badge-booked">
|
||||
<text class="badge-text">已预约</text>
|
||||
</view>
|
||||
<view class="btn-cancel" @tap.stop="emit('cancel', slot)">
|
||||
<text class="btn-cancel-text">取消</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- FULL or CLOSED -->
|
||||
<template v-else>
|
||||
<view class="btn btn-disabled">
|
||||
<text class="btn-text">
|
||||
{{ slot.status === TimeSlotStatus.FULL ? '已约满' : '已关闭' }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Q: Where is the API request actually made?
|
||||
**A:** `utils/request.ts:22-59`
|
||||
```typescript
|
||||
export function request<T>(options: RequestOptions): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('token') as string
|
||||
|
||||
uni.request({
|
||||
url: `${BASE_URL}${options.url}`, // BASE_URL = http://localhost:3000/api
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.header,
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
uni.removeStorageSync('token')
|
||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
||||
reject(new Error('Unauthorized'))
|
||||
return
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
const body = res.data as ApiResponse<unknown>
|
||||
reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
|
||||
return
|
||||
}
|
||||
const body = res.data as ApiResponse<T>
|
||||
if (body.success) {
|
||||
resolve(body.data as T) // ← Extract data from ApiResponse
|
||||
} else {
|
||||
reject(new Error(body.message || '请求失败'))
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(new Error(err.errMsg || '网络请求失败'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Debugging Tips
|
||||
|
||||
### Tip 1: Check what's in the store
|
||||
```typescript
|
||||
// In browser console while in booking page:
|
||||
console.log('Slots:', JSON.stringify(uni.$u.pinia.state.value.booking.slots, null, 2))
|
||||
console.log('Selected period:', uni.$u.pinia.state.value.booking.selectedPeriod)
|
||||
```
|
||||
|
||||
### Tip 2: Log slot filtering
|
||||
```typescript
|
||||
// Add to pages/booking/index.vue filteredSlots computed:
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||
if (!selectedPeriod.value) {
|
||||
console.log('No period filter, showing all slots:', slots.length)
|
||||
return [...slots]
|
||||
}
|
||||
|
||||
const period = TIME_PERIODS[selectedPeriod.value]
|
||||
console.log(`Filtering by ${selectedPeriod.value}:`, period)
|
||||
console.log('All slot times:', slots.map(s => s.startTime))
|
||||
|
||||
const filtered = slots.filter((slot) => {
|
||||
const t = slot.startTime
|
||||
const matches = t >= period.start && t < period.end
|
||||
if (!matches) console.log(`${t} not in [${period.start}, ${period.end})`)
|
||||
return matches
|
||||
})
|
||||
|
||||
console.log('Filtered result:', filtered.length)
|
||||
return filtered
|
||||
})
|
||||
```
|
||||
|
||||
### Tip 3: Verify API response
|
||||
```typescript
|
||||
// In stores/booking.ts fetchSlots():
|
||||
async function fetchSlots(date: string) {
|
||||
loadingSlots.value = true
|
||||
try {
|
||||
console.log('Fetching slots for date:', date)
|
||||
slots.value = await get<TimeSlotWithBookingStatus[]>(
|
||||
'/time-slot/available',
|
||||
{ date }
|
||||
)
|
||||
console.log('Received slots:', slots.value)
|
||||
console.log('Slot count:', slots.value.length)
|
||||
if (slots.value.length > 0) {
|
||||
console.log('First slot:', JSON.stringify(slots.value[0], null, 2))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fetch slots failed:', err)
|
||||
slots.value = []
|
||||
} finally {
|
||||
loadingSlots.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tip 4: Check network requests
|
||||
```typescript
|
||||
// Open WeChat DevTools → Network tab
|
||||
// Look for GET request to /time-slot/available
|
||||
// Check:
|
||||
// ✓ URL has ?date=YYYY-MM-DD
|
||||
// ✓ Authorization header exists
|
||||
// ✓ Response status 200
|
||||
// ✓ Response body has "success": true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Common Issues & Solutions
|
||||
|
||||
### Issue 1: Slots not loading
|
||||
**Symptoms:**
|
||||
- Page shows "当日暂无可约时段" (no slots)
|
||||
- No error message
|
||||
|
||||
**Check list:**
|
||||
```typescript
|
||||
// 1. Is API endpoint correct?
|
||||
// Check: /time-slot/available?date=2026-04-05
|
||||
// Should return TimeSlotWithBookingStatus[]
|
||||
|
||||
// 2. Is date format correct?
|
||||
// Page sends: formatDate(new Date()) → "2026-04-05"
|
||||
// API expects: "YYYY-MM-DD"
|
||||
console.log(formatDate(new Date())) // Should output: "2026-04-05"
|
||||
|
||||
// 3. Is authentication working?
|
||||
console.log('Token:', uni.getStorageSync('token'))
|
||||
|
||||
// 4. Check for errors in console
|
||||
// If fetchSlots fails, slots.value becomes []
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
// In bookingStore.fetchSlots(), add error state:
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchSlots(date: string) {
|
||||
loadingSlots.value = true
|
||||
error.value = null // Clear previous error
|
||||
try {
|
||||
slots.value = await get<TimeSlotWithBookingStatus[]>(
|
||||
'/time-slot/available',
|
||||
{ date }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Fetch slots failed:', err)
|
||||
error.value = err instanceof Error ? err.message : '加载失败'
|
||||
slots.value = []
|
||||
} finally {
|
||||
loadingSlots.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Then in page template:
|
||||
<view v-if="error" class="error-wrap">
|
||||
<text>{{ error }}</text>
|
||||
<view @tap="loadSlots(selectedDate)">重试</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
### Issue 2: Time period filtering not working
|
||||
**Symptoms:**
|
||||
- Select "上午" (morning) but all slots still show
|
||||
- Or vice versa
|
||||
|
||||
**Check:**
|
||||
```typescript
|
||||
// 1. Verify TIME_PERIODS constant
|
||||
console.log('TIME_PERIODS:', TIME_PERIODS)
|
||||
|
||||
// 2. Check selectedPeriod value
|
||||
console.log('Selected period:', selectedPeriod.value)
|
||||
|
||||
// 3. Verify slot.startTime format
|
||||
// Should be "HH:MM" like "09:00", not "09:00:00"
|
||||
bookingStore.slots.forEach(slot => {
|
||||
console.log('Slot time:', slot.startTime, 'format ok?', /^\d{2}:\d{2}$/.test(slot.startTime))
|
||||
})
|
||||
|
||||
// 4. Test filtering manually
|
||||
const slot = bookingStore.slots[0]
|
||||
const period = TIME_PERIODS.MORNING
|
||||
console.log(`${slot.startTime} >= ${period.start}?`, slot.startTime >= period.start)
|
||||
console.log(`${slot.startTime} < ${period.end}?`, slot.startTime < period.end)
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
// If time format is "09:00:00", slice it:
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||
if (!selectedPeriod.value) return [...slots]
|
||||
|
||||
const period = TIME_PERIODS[selectedPeriod.value]
|
||||
return slots.filter((slot) => {
|
||||
// Ensure HH:MM format
|
||||
const t = slot.startTime.slice(0, 5) // "09:00:00" → "09:00"
|
||||
return t >= period.start && t < period.end
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Issue 3: Booking button not responding
|
||||
**Symptoms:**
|
||||
- Click "可预约" but nothing happens
|
||||
- No modal appears
|
||||
|
||||
**Check:**
|
||||
```typescript
|
||||
// 1. Is slot.status correct?
|
||||
console.log('Slot status:', slot.status)
|
||||
// Should be "OPEN" to show book button
|
||||
|
||||
// 2. Is isBookedByMe false?
|
||||
console.log('Is booked by me?', slot.isBookedByMe)
|
||||
// Should be false to show book button
|
||||
|
||||
// 3. Is onBookTap being called?
|
||||
// Add to pages/booking/index.vue:
|
||||
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
||||
console.log('Book tapped for slot:', slot) // ← Should log
|
||||
|
||||
// Rest of code...
|
||||
}
|
||||
|
||||
// 4. Is userStore.loggedIn true?
|
||||
console.log('Logged in?', userStore.loggedIn)
|
||||
```
|
||||
|
||||
### Issue 4: Membership not showing in popup
|
||||
**Symptoms:**
|
||||
- Booking popup appears but no membership card shown
|
||||
- "暂无可用会员卡" displayed
|
||||
|
||||
**Check:**
|
||||
```typescript
|
||||
// 1. Are memberships loaded?
|
||||
console.log('Memberships:', userStore.memberships)
|
||||
|
||||
// 2. Are any memberships ACTIVE?
|
||||
console.log('Active memberships:', userStore.activeMemberships)
|
||||
console.log('Has valid membership?', userStore.hasValidMembership)
|
||||
|
||||
// 3. Are memberships passed to popup?
|
||||
// In pages/booking/index.vue:
|
||||
<BookingConfirmPopup
|
||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||
...
|
||||
/>
|
||||
console.log('Popup passed memberships:', userStore.activeMemberships)
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
// In onMounted:
|
||||
onMounted(async () => {
|
||||
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
|
||||
console.log('Fetching memberships...')
|
||||
try {
|
||||
await userStore.fetchMemberships()
|
||||
console.log('Memberships loaded:', userStore.activeMemberships)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch memberships:', err)
|
||||
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
await loadSlots(selectedDate.value)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Capacity Display Logic
|
||||
|
||||
### How Capacity Color is Determined
|
||||
```typescript
|
||||
// components/SlotCard.vue:69-81
|
||||
const capacityLabel = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.slot
|
||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||
return `${bookedCount}/${capacity} 人`
|
||||
})
|
||||
|
||||
const capacityClass = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.slot
|
||||
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
|
||||
if (status === TimeSlotStatus.FULL) return 'cap-full'
|
||||
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
||||
return 'cap-open'
|
||||
})
|
||||
|
||||
// Color mapping in styles:
|
||||
// cap-open: #f0faf3 bg, #4caf50 text (green) - <80% booked
|
||||
// cap-almost: #fff8ed bg, #f59e0b text (orange) - ≥80% booked
|
||||
// cap-full: #fef0f0 bg, #ef4444 text (red) - status: FULL
|
||||
// cap-closed: #f5f5f5 bg, #999 text (gray) - status: CLOSED
|
||||
```
|
||||
|
||||
### Example Calculations
|
||||
```typescript
|
||||
// Slot 1: capacity=1, bookedCount=0, status=OPEN
|
||||
// 0/1 人 in green badge (0% booked)
|
||||
|
||||
// Slot 2: capacity=1, bookedCount=1, status=OPEN
|
||||
// 1/1 人 in red badge (100% booked ≥ 80%)
|
||||
|
||||
// Slot 3: capacity=5, bookedCount=4, status=OPEN
|
||||
// 4/5 人 in orange badge (80% booked ≥ 80%)
|
||||
|
||||
// Slot 4: capacity=5, bookedCount=3, status=OPEN
|
||||
// 3/5 人 in green badge (60% booked < 80%)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 API Contract Summary
|
||||
|
||||
### GET /time-slot/available
|
||||
|
||||
**Request:**
|
||||
```
|
||||
GET /api/time-slot/available?date=2026-04-05
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"date": "2026-04-05",
|
||||
"startTime": "09:00",
|
||||
"endTime": "10:00",
|
||||
"capacity": 1,
|
||||
"bookedCount": 0,
|
||||
"status": "OPEN",
|
||||
"source": "MANUAL",
|
||||
"templateId": null,
|
||||
"createdAt": "2026-04-01T10:00:00Z",
|
||||
"updatedAt": "2026-04-05T09:00:00Z",
|
||||
"isBookedByMe": false,
|
||||
"myBookingId": null
|
||||
}
|
||||
],
|
||||
"message": null
|
||||
}
|
||||
```
|
||||
|
||||
**Error (400):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": "Invalid date format"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /booking
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
POST /api/booking
|
||||
{
|
||||
"timeSlotId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"membershipId": "220e8400-e29b-41d4-a716-446655440111"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440222",
|
||||
"userId": "user-123",
|
||||
"timeSlotId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"membershipId": "220e8400-e29b-41d4-a716-446655440111",
|
||||
"status": "CONFIRMED",
|
||||
"bookedAt": "2026-04-05T10:30:00Z",
|
||||
"courseDate": "2026-04-05",
|
||||
"courseTime": "09:00",
|
||||
"instructorName": "instructor name",
|
||||
"isCompleted": false
|
||||
},
|
||||
"message": null
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /booking/:id/cancel
|
||||
|
||||
**Request:**
|
||||
```
|
||||
PUT /api/booking/550e8400-e29b-41d4-a716-446655440222/cancel
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440222",
|
||||
"status": "CANCELLED",
|
||||
"cancelledAt": "2026-04-05T10:35:00Z"
|
||||
},
|
||||
"message": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps for Debugging
|
||||
|
||||
1. **Verify API Endpoint**
|
||||
- Open DevTools → Network
|
||||
- Check `/time-slot/available?date=...` request
|
||||
- Confirm response has `"success": true`
|
||||
- Confirm data array is not empty
|
||||
|
||||
2. **Check Store State**
|
||||
- Add console.logs to bookingStore.fetchSlots()
|
||||
- Verify slots are set correctly
|
||||
- Check loadingSlots toggle
|
||||
|
||||
3. **Verify Computed Properties**
|
||||
- Log filteredSlots in component
|
||||
- Check if filtering logic works
|
||||
- Verify slot.startTime format
|
||||
|
||||
4. **Test User Interaction**
|
||||
- Click date item → verify onDateSelect fires
|
||||
- Click period tab → verify onPeriodChange fires
|
||||
- Click book button → verify onBookTap fires
|
||||
- Check modals appear
|
||||
|
||||
5. **Check Mobile-Specific Issues**
|
||||
- Test in WeChat DevTools
|
||||
- Check rpx calculations
|
||||
- Verify touch events work
|
||||
|
||||
Reference in New Issue
Block a user