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:
@@ -53,19 +53,20 @@
|
||||
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
|
||||
</view>
|
||||
<view v-else class="agenda">
|
||||
<view v-for="slot in slots" :key="slot.slotId" class="session">
|
||||
<view v-for="slot in slots" :key="slot.slotId" class="session" hover-class="session--hover"
|
||||
:aria-label="`${slot.startTime.slice(0, 5)} 至 ${slot.endTime.slice(0, 5)},${slot.students.length} 人`" @tap="openSlot(slot.slotId)">
|
||||
<view class="session__time">
|
||||
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
|
||||
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
|
||||
</view>
|
||||
<view class="session__roster">
|
||||
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} 人</text></view>
|
||||
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} 人 ›</text></view>
|
||||
<view v-for="student in slot.students" :key="student.bookingId" class="student">
|
||||
<view class="student__headline">
|
||||
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
|
||||
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
|
||||
</view>
|
||||
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap="contactStudent(student.phone)">
|
||||
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap.stop="contactStudent(student.phone)">
|
||||
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 ↗</text>
|
||||
</button>
|
||||
<text v-else class="student__no-phone">未留手机号</text>
|
||||
@@ -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, string> = {
|
||||
[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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user