From 15d96c722b79f45f98adaf79cf568509bf00941d Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 6 Sep 2026 20:12:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=8E=92=E8=AF=BE=E8=8A=82=E5=A5=8F=E5=B9=B6=E9=99=90=E5=88=B6?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一节 08:00-09:00,之后从 09:30 起每小时一节至 21:30;排课时间仅可选整点或半点。 Co-authored-by: Cursor --- packages/app/src/pages/admin/schedule.vue | 50 ++++++++------ packages/app/src/pages/admin/slot-adjust.vue | 31 +++++++-- packages/app/src/utils/schedule-time.ts | 67 +++++++++++++++++++ .../__tests__/slot-generator.service.spec.ts | 17 ++--- .../src/time-slot/slot-generator.service.ts | 2 +- packages/shared/src/constants.ts | 41 +++++++++--- packages/shared/src/index.ts | 6 +- 7 files changed, 168 insertions(+), 46 deletions(-) create mode 100644 packages/app/src/utils/schedule-time.ts diff --git a/packages/app/src/pages/admin/schedule.vue b/packages/app/src/pages/admin/schedule.vue index 7132b1e..7d82c48 100644 --- a/packages/app/src/pages/admin/schedule.vue +++ b/packages/app/src/pages/admin/schedule.vue @@ -40,9 +40,10 @@ {{ slot.startTime }} @@ -50,9 +51,10 @@ {{ slot.endTime }} @@ -112,8 +114,9 @@ 开始时间 @@ -162,6 +165,14 @@ import { getSystemLayout } from '../../utils/system' import { useAdminStore } from '../../stores/admin' import { formatDate } from '../../utils/format' import DateSelector from '../../components/DateSelector.vue' +import { + SCHEDULE_TIME_PICKER_RANGE, + timeToPickerIndex, + pickerIndexToTime, + addOneHourCapped, +} from '../../utils/schedule-time' + +const timePickerRange = SCHEDULE_TIME_PICKER_RANGE interface EditableSlot { readonly key: string @@ -186,8 +197,8 @@ const showAddModal = ref(false) const editableSlots = ref([]) const addForm = ref({ - startTime: '09:00', - endTime: '10:00', + startTime: '09:30', + endTime: '10:30', capacityStr: '1', }) @@ -270,23 +281,14 @@ function removeSlot(slot: EditableSlot) { // ── Add slot ────────────────────────────────────────────── -/** 将 "HH:mm" 加一小时,最大 23:59 */ -function addOneHour(time: string): string { - const [h, m] = time.split(':').map(Number) - const newH = Math.min(h + 1, 23) - // 如果原本就是 23:xx,结束时间设为 23:59 - if (h >= 23) return '23:59' - return String(newH).padStart(2, '0') + ':' + String(m).padStart(2, '0') -} - function onAddStartTimeChange(e: any) { - const start = e.detail.value as string + const start = pickerIndexToTime(e.detail.value as number[]) addForm.value.startTime = start - addForm.value.endTime = addOneHour(start) + addForm.value.endTime = addOneHourCapped(start) } function openAddModal() { - addForm.value = { startTime: '09:00', endTime: '10:00', capacityStr: '1' } + addForm.value = { startTime: '09:30', endTime: '10:30', capacityStr: '1' } showAddModal.value = true } @@ -305,6 +307,10 @@ function submitAdd() { uni.showToast({ title: '请选择时间', icon: 'none' }) return } + if (addForm.value.startTime >= addForm.value.endTime) { + uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' }) + return + } if (isNaN(capacity) || capacity < 1) { uni.showToast({ title: '请填写有效容量', icon: 'none' }) return diff --git a/packages/app/src/pages/admin/slot-adjust.vue b/packages/app/src/pages/admin/slot-adjust.vue index 8cf2eca..76c9720 100644 --- a/packages/app/src/pages/admin/slot-adjust.vue +++ b/packages/app/src/pages/admin/slot-adjust.vue @@ -28,7 +28,12 @@ 开始时间 - + {{ addForm.startTime || '请选择' }} @@ -37,7 +42,12 @@ 结束时间 - + {{ addForm.endTime || '请选择' }} @@ -128,7 +138,7 @@ - 将按默认时间表(每天 8:00-22:00,每小时一节)自动生成所选日期范围内的时段 + 将按默认时间表(8:00-9:00,之后从 9:30 起每小时一节至 21:30)自动生成所选日期范围内的时段 {{ submitting ? '生成中...' : '批量生成' }} @@ -145,6 +155,13 @@ import { getSystemLayout } from '../../utils/system' import { useAdminStore } from '../../stores/admin' import { formatDate } from '../../utils/format' import type { TimeSlot } from '@mp-pilates/shared' +import { + SCHEDULE_TIME_PICKER_RANGE, + timeToPickerIndex, + pickerIndexToTime, +} from '../../utils/schedule-time' + +const timePickerRange = SCHEDULE_TIME_PICKER_RANGE const adminStore = useAdminStore() @@ -157,8 +174,8 @@ const slotsLoading = ref(false) // ── Add slot form ──────────────────────────────────────────────── const addForm = ref({ date: formatDate(new Date()), - startTime: '09:00', - endTime: '10:00', + startTime: '09:30', + endTime: '10:30', capacityStr: '10', }) @@ -168,6 +185,10 @@ async function submitAddSlot() { uni.showToast({ title: '请填写完整信息', icon: 'none' }) return } + if (addForm.value.startTime >= addForm.value.endTime) { + uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' }) + return + } const capacity = parseInt(addForm.value.capacityStr, 10) submitting.value = true try { diff --git a/packages/app/src/utils/schedule-time.ts b/packages/app/src/utils/schedule-time.ts new file mode 100644 index 0000000..21f564d --- /dev/null +++ b/packages/app/src/utils/schedule-time.ts @@ -0,0 +1,67 @@ +import { + DEFAULT_SCHEDULE_START_TIME, + DEFAULT_SCHEDULE_END_TIME, + SCHEDULE_MINUTE_STEP, +} from '@mp-pilates/shared' + +function buildHourOptions(): string[] { + const startHour = parseInt(DEFAULT_SCHEDULE_START_TIME.slice(0, 2), 10) + const endHour = parseInt(DEFAULT_SCHEDULE_END_TIME.slice(0, 2), 10) + const list: string[] = [] + for (let h = startHour; h <= endHour; h++) { + list.push(String(h).padStart(2, '0')) + } + return list +} + +function buildMinuteOptions(): string[] { + const list: string[] = [] + for (let m = 0; m < 60; m += SCHEDULE_MINUTE_STEP) { + list.push(String(m).padStart(2, '0')) + } + return list +} + +export const SCHEDULE_HOUR_OPTIONS = buildHourOptions() +export const SCHEDULE_MINUTE_OPTIONS = buildMinuteOptions() +export const SCHEDULE_TIME_PICKER_RANGE = [SCHEDULE_HOUR_OPTIONS, SCHEDULE_MINUTE_OPTIONS] + +export function timeToMinutes(time: string): number { + const [h, m] = time.split(':').map(Number) + return h * 60 + (m || 0) +} + +export function minutesToTime(total: number): string { + const h = Math.floor(total / 60) + const m = total % 60 + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` +} + +export function timeToPickerIndex(time: string): number[] { + let total = timeToMinutes(time || DEFAULT_SCHEDULE_START_TIME) + const minTotal = timeToMinutes(DEFAULT_SCHEDULE_START_TIME) + const maxTotal = timeToMinutes(DEFAULT_SCHEDULE_END_TIME) + total = Math.max(minTotal, Math.min(maxTotal, total)) + total = Math.round(total / SCHEDULE_MINUTE_STEP) * SCHEDULE_MINUTE_STEP + total = Math.max(minTotal, Math.min(maxTotal, total)) + + const hour = String(Math.floor(total / 60)).padStart(2, '0') + const minute = String(total % 60).padStart(2, '0') + return [ + Math.max(0, SCHEDULE_HOUR_OPTIONS.indexOf(hour)), + Math.max(0, SCHEDULE_MINUTE_OPTIONS.indexOf(minute)), + ] +} + +export function pickerIndexToTime(value: number[]): string { + const hour = SCHEDULE_HOUR_OPTIONS[value[0]] ?? SCHEDULE_HOUR_OPTIONS[0] + const minute = SCHEDULE_MINUTE_OPTIONS[value[1]] ?? SCHEDULE_MINUTE_OPTIONS[0] + return `${hour}:${minute}` +} + +/** 将 "HH:mm" 加一小时,不超过当日最晚结束时间 21:30 */ +export function addOneHourCapped(time: string): string { + const next = timeToMinutes(time) + 60 + const cap = timeToMinutes(DEFAULT_SCHEDULE_END_TIME) + return minutesToTime(Math.min(next, cap)) +} diff --git a/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts b/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts index 5d889fd..1d97398 100644 --- a/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts +++ b/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts @@ -51,7 +51,7 @@ describe('SlotGeneratorService', () => { // ------------------------------------------------------------------------- describe('generateSlots', () => { - it('creates slots for every day using the default schedule (14 slots per day)', async () => { + it('creates slots for every day using the default schedule', async () => { const defaultSlots = getDefaultTimeSlots() mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length * 7 }) @@ -64,24 +64,25 @@ describe('SlotGeneratorService', () => { skipDuplicates: boolean } expect(skipDuplicates).toBe(true) - // 7 days × 14 slots per day = 98 expect(data).toHaveLength(defaultSlots.length * 7) expect(count).toBe(defaultSlots.length * 7) }) - it('creates 14 slots per day (08:00-22:00 hourly)', async () => { - mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 }) + it('creates 13 slots per day (08:00-09:00, then 09:30-21:30 hourly)', async () => { + mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 }) await service.generateSlots(1) const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as { data: Array<{ startTime: string; endTime: string }> } - expect(data).toHaveLength(14) + expect(data).toHaveLength(13) expect(data[0].startTime).toBe('08:00') expect(data[0].endTime).toBe('09:00') - expect(data[13].startTime).toBe('21:00') - expect(data[13].endTime).toBe('22:00') + expect(data[1].startTime).toBe('09:30') + expect(data[1].endTime).toBe('10:30') + expect(data[12].startTime).toBe('20:30') + expect(data[12].endTime).toBe('21:30') }) it('passes skipDuplicates: true to handle existing date+time combinations', async () => { @@ -96,7 +97,7 @@ describe('SlotGeneratorService', () => { }) it('sets source to TEMPLATE for all generated slots', async () => { - mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 }) + mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 }) await service.generateSlots(1) diff --git a/packages/server/src/time-slot/slot-generator.service.ts b/packages/server/src/time-slot/slot-generator.service.ts index 7c18f6d..f38d997 100644 --- a/packages/server/src/time-slot/slot-generator.service.ts +++ b/packages/server/src/time-slot/slot-generator.service.ts @@ -25,7 +25,7 @@ export class SlotGeneratorService { /** * Generate time slots for the next `daysAhead` days based on the fixed - * default schedule (Mon-Sun, 08:00-22:00 hourly). + * default schedule (Mon-Sun: 08:00-09:00, then 09:30-21:30 hourly). * Uses `createMany` with `skipDuplicates` so re-runs are safe. * * @returns Number of newly created slots diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 68e690f..8d2d451 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -15,17 +15,42 @@ export const DEFAULT_SLOT_CAPACITY = 1 /** 自动生成时段的天数范围 */ export const SLOT_GENERATION_DAYS = 14 -/** 默认排课时间表:每天 08:00-22:00,每小时一节课 */ -export const DEFAULT_SCHEDULE_START_HOUR = 8 -export const DEFAULT_SCHEDULE_END_HOUR = 22 +/** 默认排课时间表:第一节 08:00-09:00,之后从 09:30 起每小时一节至 21:30 */ +export const DEFAULT_SCHEDULE_START_TIME = '08:00' +export const DEFAULT_SCHEDULE_END_TIME = '21:30' +/** 第一节课后的半点课表起点 */ +export const DEFAULT_SCHEDULE_OFFSET_START_TIME = '09:30' +/** 排课时间选择步长:仅整点与半点 */ +export const SCHEDULE_MINUTE_STEP = 30 + +function parseTimeToMinutes(time: string): number { + const [hours, minutes] = time.split(':').map(Number) + return hours * 60 + (minutes || 0) +} + +function formatMinutesToTime(totalMinutes: number): string { + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}` +} /** 生成默认时段列表 (startTime, endTime) */ export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> { - const slots: Array<{ startTime: string; endTime: string }> = [] - for (let h = DEFAULT_SCHEDULE_START_HOUR; h < DEFAULT_SCHEDULE_END_HOUR; h++) { - const startTime = String(h).padStart(2, '0') + ':00' - const endTime = String(h + 1).padStart(2, '0') + ':00' - slots.push({ startTime, endTime }) + const duration = 60 + const slots: Array<{ startTime: string; endTime: string }> = [ + { startTime: DEFAULT_SCHEDULE_START_TIME, endTime: '09:00' }, + ] + const endMinutes = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME) + + for ( + let t = parseTimeToMinutes(DEFAULT_SCHEDULE_OFFSET_START_TIME); + t + duration <= endMinutes; + t += duration + ) { + slots.push({ + startTime: formatMinutesToTime(t), + endTime: formatMinutesToTime(t + duration), + }) } return slots } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6bc27a4..8f5ba2b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,8 +19,10 @@ export { DEFAULT_STUDIO_GALLERY_PHOTOS, DEFAULT_SLOT_CAPACITY, SLOT_GENERATION_DAYS, - DEFAULT_SCHEDULE_START_HOUR, - DEFAULT_SCHEDULE_END_HOUR, + DEFAULT_SCHEDULE_START_TIME, + DEFAULT_SCHEDULE_END_TIME, + DEFAULT_SCHEDULE_OFFSET_START_TIME, + SCHEDULE_MINUTE_STEP, getDefaultTimeSlots, TIME_PERIODS, DATE_SELECTOR_DAYS,