diff --git a/packages/app/preview/teaching-schedule.spec.mjs b/packages/app/preview/teaching-schedule.spec.mjs new file mode 100644 index 0000000..7e0be08 --- /dev/null +++ b/packages/app/preview/teaching-schedule.spec.mjs @@ -0,0 +1,43 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { transform } from 'esbuild' + +const require = createRequire(import.meta.url) +const source = await readFile(new URL('../src/utils/teaching-schedule.ts', import.meta.url), 'utf8') +const { code } = await transform(source, { loader: 'ts', format: 'cjs' }) +const module = { exports: {} } +new Function('require', 'module', 'exports', code)(require, module, module.exports) +const { getTeachingScheduleLessons } = module.exports + +function slot(id, statuses) { + return { + slotId: id, date: '2026-10-29', startTime: '18:00', endTime: '19:00', bookedCount: statuses.length, capacity: 5, + students: statuses.map((status, index) => ({ bookingId: `${id}-${index}`, userId: `u-${index}`, nickname: '测试', phone: null, status })), + } +} + +test('只计待确认、已确认、已完成;取消和未出席的独立时段不计课', () => { + const result = getTeachingScheduleLessons([ + slot('pending', ['PENDING_CONFIRMATION']), slot('confirmed', ['CONFIRMED']), slot('completed', ['COMPLETED']), + slot('cancelled', ['CANCELLED']), slot('no-show', ['NO_SHOW']), slot('empty', []), + ]) + assert.deepEqual(result.map(item => item.slotId), ['pending', 'confirmed', 'completed']) + assert.equal(result.reduce((sum, item) => sum + item.students.length, 0), 3) +}) + +test('混合状态团课仍计一节课,人数只计有效预约,且保留完成状态', () => { + const source = [slot('group', ['CANCELLED', 'CONFIRMED', 'COMPLETED', 'NO_SHOW'])] + const snapshot = structuredClone(source) + const result = getTeachingScheduleLessons(source) + assert.equal(result.length, 1) + assert.equal(result[0].students.length, 2) + assert.deepEqual(result[0].students.map(student => student.status), ['CONFIRMED', 'COMPLETED']) + assert.deepEqual(source, snapshot) +}) + +test('全部取消或无预约时,课程数和人数均为零', () => { + assert.deepEqual(getTeachingScheduleLessons([slot('cancelled', ['CANCELLED', 'CANCELLED'])]), []) + assert.deepEqual(getTeachingScheduleLessons([]), []) +}) diff --git a/packages/app/src/components/TodayLessonsCard.vue b/packages/app/src/components/TodayLessonsCard.vue index d481e51..f8313bc 100644 --- a/packages/app/src/components/TodayLessonsCard.vue +++ b/packages/app/src/components/TodayLessonsCard.vue @@ -41,6 +41,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { BookingStatus, type TeachingScheduleSlot } from '@mp-pilates/shared' import { get } from '../utils/request' import { formatDate } from '../utils/format' +import { getTeachingScheduleLessons } from '../utils/teaching-schedule' const props = defineProps<{ refreshKey: number }>() @@ -59,11 +60,10 @@ function toMinutes(time: string) { } // One time slot is one lesson, so a small-group class is never double-counted. -// Slots whose bookings were all cancelled are not lessons, and a lesson is +// Cancelled / no-show bookings are not lessons, and a lesson is // done once at least one of its students has been verified (COMPLETED). const stats = computed(() => { - const lessons = slots.value - .filter(slot => slot.students.some(student => student.status !== BookingStatus.CANCELLED)) + const lessons = getTeachingScheduleLessons(slots.value) .map(slot => ({ startTime: slot.startTime, endTime: slot.endTime, diff --git a/packages/app/src/pages/profile/teaching-schedule.vue b/packages/app/src/pages/profile/teaching-schedule.vue index 9d46cb7..18cef4e 100644 --- a/packages/app/src/pages/profile/teaching-schedule.vue +++ b/packages/app/src/pages/profile/teaching-schedule.vue @@ -90,6 +90,7 @@ import { useUserStore } from '../../stores/user' import { formatDate, getWeekdayLabel, isToday } from '../../utils/format' import { getSystemLayout } from '../../utils/system' import { getErrorMessage } from '../../utils/auth' +import { getTeachingScheduleLessons } from '../../utils/teaching-schedule' const bookingStore = useBookingStore() const { loggedIn, isAdmin } = storeToRefs(useUserStore()) @@ -163,7 +164,7 @@ async function loadSchedule(date: string) { loading.value = true try { const result = await bookingStore.fetchTeachingSchedule(date) - if (id === requestId) slots.value = [...result].sort((a, b) => a.startTime.localeCompare(b.startTime)) + if (id === requestId) slots.value = getTeachingScheduleLessons(result).sort((a, b) => a.startTime.localeCompare(b.startTime)) } catch (err: unknown) { if (id === requestId) error.value = getErrorMessage(err, '请检查网络后重试') } finally { diff --git a/packages/app/src/utils/teaching-schedule.ts b/packages/app/src/utils/teaching-schedule.ts new file mode 100644 index 0000000..0964ef6 --- /dev/null +++ b/packages/app/src/utils/teaching-schedule.ts @@ -0,0 +1,14 @@ +import { BookingStatus, type TeachingScheduleSlot } from '@mp-pilates/shared' + +const LESSON_STATUSES = new Set([ + BookingStatus.PENDING_CONFIRMATION, + BookingStatus.CONFIRMED, + BookingStatus.COMPLETED, +]) + +/** 课表和今日课程采用同一口径;取消、未出席不计课,不修改原始预约记录。 */ +export function getTeachingScheduleLessons(slots: readonly TeachingScheduleSlot[]): TeachingScheduleSlot[] { + return slots + .map(slot => ({ ...slot, students: slot.students.filter(student => LESSON_STATUSES.has(student.status)) })) + .filter(slot => slot.students.length > 0) +}