Files
mp-pilates/packages/app/src/stores/booking.ts
richarjiang d91282ecbd fix: 修复身体画像约课阻断、页面响应式丢失及多项边界问题
- fix(booking): 修复未认领画像阻断约课问题,支持约课事务内自动认领绑定
- fix(app): 修复 Pinia 状态解构导致响应式失效,补齐 PortraitRadar 组件实例传参
- fix(admin): 修复经营助手复测待办点击 404,优化体验课后线索回访文案
- fix(server): 线下评估日期间隔按中国时区校验,复测按期次单项核销,优化单次评估分享卡与限流清理
- test: 补齐 body-portrait-offline 与 booking 来源画像单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 22:45:44 +08:00

188 lines
5.9 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref } from 'vue'
import type {
TimeSlotWithBookingStatus,
BookingWithDetails,
BookingWithUser,
BookingStatusHistory,
CreateBookingDto,
TeachingScheduleSlot,
} from '@mp-pilates/shared'
import { get, post, put } from '../utils/request'
import { useBodyPortraitStore } from './body-portrait'
/** Server paginated responses use `data` field, not `items` from the shared type */
interface ServerPaginatedResult<T> {
readonly data: readonly T[]
readonly total: number
readonly page: number
readonly limit: number
}
export const useBookingStore = defineStore('booking', () => {
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
const myBookings = ref<readonly BookingWithDetails[]>([])
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
const loadingSlots = ref(false)
const loadingBookings = ref(false)
const loadingTeachingSchedule = ref(false)
async function fetchSlots(date: string) {
loadingSlots.value = true
try {
slots.value = await get<TimeSlotWithBookingStatus[]>('/time-slot/available', { date })
} catch (err) {
console.error('Fetch slots failed:', err)
slots.value = []
} finally {
loadingSlots.value = false
}
}
async function createBooking(dto: CreateBookingDto) {
const portraitStore = useBodyPortraitStore()
const originAssessmentId = dto.originAssessmentId || (portraitStore.claimed ? portraitStore.assessmentId : undefined)
const result = await post<BookingWithDetails>('/booking', {
...dto,
...(originAssessmentId ? { originAssessmentId } : {}),
} as unknown as Record<string, unknown>)
return result
}
/**
* Replace a booking in `myBookings` by id. Preserves immutability: always
* returns a new array reference so Vue's computed/watchers pick up the change.
* If the booking isn't in the list (e.g. paginated out), leaves state untouched.
*/
function replaceBooking(updated: BookingWithDetails) {
const idx = myBookings.value.findIndex((b) => b.id === updated.id)
if (idx === -1) return
const next = myBookings.value.slice()
next[idx] = updated
myBookings.value = next
}
async function cancelBooking(bookingId: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
replaceBooking(result)
return result
}
async function fetchMyBookings(status?: string, opts: { silent?: boolean } = {}) {
if (!opts.silent) loadingBookings.value = true
try {
const params: Record<string, unknown> = status ? { status } : {}
const paginated = await get<ServerPaginatedResult<BookingWithDetails>>('/booking/my', params)
myBookings.value = Array.isArray(paginated.data) ? paginated.data : []
} catch (err) {
console.error('Fetch bookings failed:', err)
myBookings.value = []
} finally {
if (!opts.silent) loadingBookings.value = false
}
}
async function fetchUpcomingBookings() {
try {
const result = await get<BookingWithDetails[]>('/booking/my/upcoming')
upcomingBookings.value = Array.isArray(result) ? result : []
} catch (err) {
console.error('Fetch upcoming bookings failed:', err)
upcomingBookings.value = []
}
}
async function fetchTeachingSchedule(date: string) {
loadingTeachingSchedule.value = true
try {
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
teachingSchedule.value = Array.isArray(result) ? result : []
return teachingSchedule.value
} catch (err) {
console.error('Fetch teaching schedule failed:', err)
teachingSchedule.value = []
throw err
} finally {
loadingTeachingSchedule.value = false
}
}
// ─── Admin methods ──────────────────────────────────────────────────────
async function fetchAllAdminBookings(
page = 1,
limit = 20,
status?: string,
): Promise<ServerPaginatedResult<BookingWithUser>> {
const params: Record<string, unknown> = { page, limit }
if (status) params.status = status
const paginated = await get<ServerPaginatedResult<BookingWithUser>>('/admin/bookings', params)
return paginated
}
async function confirmBooking(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, {
remark,
})
replaceBooking(result)
return result
}
async function completeBooking(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
remark,
})
replaceBooking(result)
return result
}
async function markNoShow(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
remark,
})
replaceBooking(result)
return result
}
async function fetchBookingHistory(bookingId: string): Promise<BookingStatusHistory[]> {
const result = await get<BookingStatusHistory[]>(`/booking/${bookingId}/history`)
return result
}
async function fetchBookingById(bookingId: string) {
const result = await get<BookingWithDetails | BookingWithUser>(`/booking/${bookingId}`)
return result
}
async function fetchSlotById(slotId: string) {
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
return result
}
return {
slots,
myBookings,
upcomingBookings,
teachingSchedule,
loadingSlots,
loadingBookings,
loadingTeachingSchedule,
fetchSlots,
createBooking,
cancelBooking,
fetchMyBookings,
fetchUpcomingBookings,
fetchTeachingSchedule,
fetchAllAdminBookings,
confirmBooking,
completeBooking,
markNoShow,
fetchBookingHistory,
fetchSlotById,
fetchBookingById,
replaceBooking,
}
})