diff --git a/packages/app/src/pages/profile/teaching-schedule.vue b/packages/app/src/pages/profile/teaching-schedule.vue index 5b3c4a4..9d46cb7 100644 --- a/packages/app/src/pages/profile/teaching-schedule.vue +++ b/packages/app/src/pages/profile/teaching-schedule.vue @@ -53,19 +53,20 @@ - + {{ slot.startTime.slice(0, 5) }} {{ slot.endTime.slice(0, 5) }} 结束 - 预约学员{{ slot.students.length }} 人 + 预约学员{{ slot.students.length }} 人 › {{ student.nickname || '未命名学员' }} {{ statusLabel(student.status) }} - 未留手机号 @@ -175,6 +176,9 @@ function formatPhone(phone: string) { function contactStudent(phone: string) { uni.makePhoneCall({ phoneNumber: phone }) } +function openSlot(slotId: string) { + uni.navigateTo({ url: `/pages/booking/detail?slotId=${encodeURIComponent(slotId)}` }) +} const STATUS_LABELS: Record = { [BookingStatus.PENDING_CONFIRMATION]: '待确认', [BookingStatus.CONFIRMED]: '已确认', @@ -219,6 +223,7 @@ button { margin: 0; padding: 0; background: transparent; font-weight: 400; borde .schedule-scroll { flex: 1; min-height: 0; height: 0; } .agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); } .session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; } +.session--hover { background: #f4f1ec; } .session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; } .session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; } .session__end { font-size: 24rpx; color: #687367; } @@ -232,6 +237,8 @@ button { margin: 0; padding: 0; background: transparent; font-weight: 400; borde .student__status--confirmed { background: #edf3ed; color: #526e62; } .student__status--pending_confirmation { background: #f8f0e3; color: #956b37; } .student__status--no_show { background: #f8eeea; color: #a06456; } +.student__status--completed { background: #e8e8e1; color: #5b6660; } +.student__status--cancelled { background: #efe5e0; color: #8c5d4f; text-decoration: line-through; } .student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; } .student__contact-label { color: #526e62; font-size: 22rpx; } .student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; } diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts index 69f2b7d..bcd577e 100644 --- a/packages/server/src/booking/__tests__/booking.service.spec.ts +++ b/packages/server/src/booking/__tests__/booking.service.spec.ts @@ -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, diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts index 574eb2e..d733a8b 100644 --- a/packages/server/src/booking/booking.service.ts +++ b/packages/server/src/booking/booking.service.ts @@ -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 { @@ -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