fix(teaching-schedule): 当日课表展示所有状态,并支持点入课程详情

- 后端 getTeachingScheduleByDate 在查询当天时不再以 PENDING/CONFIRMED 过滤 timeSlot
  也不限制 include.bookings 的状态,但仍保留 EXISTS 守卫过滤空 slot
- 其他日期维持原「只看待上课」语义
- 抽出私有 formatLocalDate 工具方法,消除服务端 YYYY-MM-DD 构造的 3 处重复
- 前端 teaching-schedule 的 session 卡片可点击跳到 /pages/booking/detail?slotId=
- 补全 student__status--completed / --cancelled 样式
This commit is contained in:
richarjiang
2026-09-09 14:51:06 +08:00
parent 75ef5e94a6
commit c3e46f7ffa
3 changed files with 125 additions and 18 deletions

View File

@@ -1138,7 +1138,7 @@ describe('BookingService', () => {
},
])
const result = await service.getTeachingScheduleByDate('2026-04-19')
const result = await service.getTeachingScheduleByDate('2099-12-31')
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1158,7 +1158,7 @@ describe('BookingService', () => {
expect(result).toEqual([
{
slotId: 'slot-01',
date: '2026-04-19',
date: '2099-12-31',
startTime: '09:00',
endTime: '10:00',
bookedCount: 2,
@@ -1175,7 +1175,7 @@ describe('BookingService', () => {
},
{
slotId: 'slot-02',
date: '2026-04-19',
date: '2099-12-31',
startTime: '11:00',
endTime: '12:00',
bookedCount: 1,
@@ -1193,6 +1193,98 @@ describe('BookingService', () => {
])
})
it('returns all-status bookings when the date is today', async () => {
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
{
id: 'slot-today',
startTime: '10:00',
endTime: '11:00',
bookedCount: 2,
capacity: 3,
bookings: [
{
id: 'booking-completed',
status: BookingStatus.COMPLETED,
createdAt: new Date(`${today}T00:00:00Z`),
user: { id: 'user-1', nickname: '完成', phone: '13800000001' },
},
{
id: 'booking-no-show',
status: BookingStatus.NO_SHOW,
createdAt: new Date(`${today}T00:00:01Z`),
user: { id: 'user-2', nickname: '未到', phone: null },
},
{
id: 'booking-cancelled',
status: BookingStatus.CANCELLED,
createdAt: new Date(`${today}T00:00:02Z`),
user: { id: 'user-3', nickname: '取消', phone: '13800000003' },
},
{
id: 'booking-confirmed',
status: BookingStatus.CONFIRMED,
createdAt: new Date(`${today}T00:00:03Z`),
user: { id: 'user-4', nickname: '确认', phone: null },
},
],
},
])
const result = await service.getTeachingScheduleByDate(today)
// 当日课表timeSlot 仍用 EXISTS 守卫过滤完全没人预约的空 slot不带状态条件
// 且 included bookings 不再限制状态。
const callArg = (prisma.timeSlot.findMany as jest.Mock).mock.calls[0][0]
expect(callArg.where).toEqual({
date: expect.any(Date),
bookings: { some: {} },
})
expect(callArg.include.bookings.where).toBeUndefined()
expect(result).toEqual([
{
slotId: 'slot-today',
date: today,
startTime: '10:00',
endTime: '11:00',
bookedCount: 2,
capacity: 3,
students: [
{
bookingId: 'booking-completed',
userId: 'user-1',
nickname: '完成',
phone: '13800000001',
status: BookingStatus.COMPLETED,
},
{
bookingId: 'booking-no-show',
userId: 'user-2',
nickname: '未到',
phone: null,
status: BookingStatus.NO_SHOW,
},
{
bookingId: 'booking-cancelled',
userId: 'user-3',
nickname: '取消',
phone: '13800000003',
status: BookingStatus.CANCELLED,
},
{
bookingId: 'booking-confirmed',
userId: 'user-4',
nickname: '确认',
phone: null,
status: BookingStatus.CONFIRMED,
},
],
},
])
})
it('rejects invalid date input', async () => {
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
BadRequestException,

View File

@@ -764,20 +764,22 @@ export class BookingService {
throw new BadRequestException('Invalid date')
}
// 当日的课表需要包含所有状态的预约:老师下课后,已核销 / 标记未到 / 已取消
// 的预约不应从课表里消失,状态由前端标签呈现;其他日期维持「只看待上课」语义。
const showAllStatuses = this.isLocalToday(date)
const activeBookingFilter = {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
}
const slots = await this.prisma.timeSlot.findMany({
where: {
date: dayStart,
bookings: {
some: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
// 仍用 EXISTS 守卫过滤掉完全没人预约的空 slot但今天放开状态过滤。
bookings: { some: showAllStatuses ? {} : activeBookingFilter },
},
include: {
bookings: {
where: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
...(showAllStatuses ? {} : { where: activeBookingFilter }),
include: {
user: {
select: {
@@ -824,6 +826,14 @@ export class BookingService {
})
}
private isLocalToday(date: string): boolean {
return date === this.formatLocalDate(new Date())
}
private formatLocalDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
@@ -855,8 +865,7 @@ export class BookingService {
}
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const dateLabel = this.formatLocalDate(booking.timeSlot.date)
await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid,
@@ -894,8 +903,7 @@ export class BookingService {
select: { nickname: true, phone: true },
})
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const dateLabel = this.formatLocalDate(booking.timeSlot.date)
const studentLabel = this.buildAdminBookingStudentLabel(student)
await Promise.allSettled(
admins