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

@@ -53,19 +53,20 @@
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button> <button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
</view> </view>
<view v-else class="agenda"> <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"> <view class="session__time">
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text> <text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text> <text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
</view> </view>
<view class="session__roster"> <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 v-for="student in slot.students" :key="student.bookingId" class="student">
<view class="student__headline"> <view class="student__headline">
<text class="student__name">{{ student.nickname || '未命名学员' }}</text> <text class="student__name">{{ student.nickname || '未命名学员' }}</text>
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text> <text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
</view> </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> <text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 </text>
</button> </button>
<text v-else class="student__no-phone">未留手机号</text> <text v-else class="student__no-phone">未留手机号</text>
@@ -175,6 +176,9 @@ function formatPhone(phone: string) {
function contactStudent(phone: string) { function contactStudent(phone: string) {
uni.makePhoneCall({ phoneNumber: phone }) uni.makePhoneCall({ phoneNumber: phone })
} }
function openSlot(slotId: string) {
uni.navigateTo({ url: `/pages/booking/detail?slotId=${encodeURIComponent(slotId)}` })
}
const STATUS_LABELS: Record<BookingStatus, string> = { const STATUS_LABELS: Record<BookingStatus, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认', [BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认', [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; } .schedule-scroll { flex: 1; min-height: 0; height: 0; }
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); } .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 { 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__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__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
.session__end { font-size: 24rpx; color: #687367; } .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--confirmed { background: #edf3ed; color: #526e62; }
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; } .student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
.student__status--no_show { background: #f8eeea; color: #a06456; } .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 { 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__contact-label { color: #526e62; font-size: 22rpx; }
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; } .student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }

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(prisma.timeSlot.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -1158,7 +1158,7 @@ describe('BookingService', () => {
expect(result).toEqual([ expect(result).toEqual([
{ {
slotId: 'slot-01', slotId: 'slot-01',
date: '2026-04-19', date: '2099-12-31',
startTime: '09:00', startTime: '09:00',
endTime: '10:00', endTime: '10:00',
bookedCount: 2, bookedCount: 2,
@@ -1175,7 +1175,7 @@ describe('BookingService', () => {
}, },
{ {
slotId: 'slot-02', slotId: 'slot-02',
date: '2026-04-19', date: '2099-12-31',
startTime: '11:00', startTime: '11:00',
endTime: '12:00', endTime: '12:00',
bookedCount: 1, 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 () => { it('rejects invalid date input', async () => {
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow( await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
BadRequestException, BadRequestException,

View File

@@ -764,20 +764,22 @@ export class BookingService {
throw new BadRequestException('Invalid date') 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({ const slots = await this.prisma.timeSlot.findMany({
where: { where: {
date: dayStart, date: dayStart,
bookings: { // 仍用 EXISTS 守卫过滤掉完全没人预约的空 slot但今天放开状态过滤。
some: { bookings: { some: showAllStatuses ? {} : activeBookingFilter },
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
}, },
include: { include: {
bookings: { bookings: {
where: { ...(showAllStatuses ? {} : { where: activeBookingFilter }),
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
include: { include: {
user: { user: {
select: { 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 Helpers ─────────────────────────────────────────────────────
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> { private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
@@ -855,8 +865,7 @@ export class BookingService {
} }
const studio = await this.studioService.getInfo() const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date const dateLabel = this.formatLocalDate(booking.timeSlot.date)
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
await this.subscriptionMessageService.sendBookingConfirmedMessage({ await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid, openid: user.openid,
@@ -894,8 +903,7 @@ export class BookingService {
select: { nickname: true, phone: true }, select: { nickname: true, phone: true },
}) })
const studio = await this.studioService.getInfo() const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date const dateLabel = this.formatLocalDate(booking.timeSlot.date)
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const studentLabel = this.buildAdminBookingStudentLabel(student) const studentLabel = this.buildAdminBookingStudentLabel(student)
await Promise.allSettled( await Promise.allSettled(
admins admins