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>
|
<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; }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user