feat: 调整默认排课节奏并限制时间选择

第一节 08:00-09:00,之后从 09:30 起每小时一节至 21:30;排课时间仅可选整点或半点。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-06 20:12:52 +08:00
parent 14d7c03b05
commit 15d96c722b
7 changed files with 168 additions and 46 deletions

View File

@@ -40,9 +40,10 @@
<view class="slot-body"> <view class="slot-body">
<view class="time-section"> <view class="time-section">
<picker <picker
mode="time" mode="multiSelector"
:value="slot.startTime" :range="timePickerRange"
@change="(e: any) => updateSlotTime(slot, 'startTime', e.detail.value)" :value="timeToPickerIndex(slot.startTime)"
@change="(e: any) => updateSlotTime(slot, 'startTime', pickerIndexToTime(e.detail.value))"
> >
<view class="time-display"> <view class="time-display">
<text class="time-text">{{ slot.startTime }}</text> <text class="time-text">{{ slot.startTime }}</text>
@@ -50,9 +51,10 @@
</picker> </picker>
<text class="time-separator"></text> <text class="time-separator"></text>
<picker <picker
mode="time" mode="multiSelector"
:value="slot.endTime" :range="timePickerRange"
@change="(e: any) => updateSlotTime(slot, 'endTime', e.detail.value)" :value="timeToPickerIndex(slot.endTime)"
@change="(e: any) => updateSlotTime(slot, 'endTime', pickerIndexToTime(e.detail.value))"
> >
<view class="time-display"> <view class="time-display">
<text class="time-text">{{ slot.endTime }}</text> <text class="time-text">{{ slot.endTime }}</text>
@@ -112,8 +114,9 @@
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">开始时间</text> <text class="modal-label">开始时间</text>
<picker <picker
mode="time" mode="multiSelector"
:value="addForm.startTime" :range="timePickerRange"
:value="timeToPickerIndex(addForm.startTime)"
@change="onAddStartTimeChange" @change="onAddStartTimeChange"
> >
<view class="picker-display"> <view class="picker-display">
@@ -162,6 +165,14 @@ import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format' import { formatDate } from '../../utils/format'
import DateSelector from '../../components/DateSelector.vue' 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 { interface EditableSlot {
readonly key: string readonly key: string
@@ -186,8 +197,8 @@ const showAddModal = ref(false)
const editableSlots = ref<EditableSlot[]>([]) const editableSlots = ref<EditableSlot[]>([])
const addForm = ref({ const addForm = ref({
startTime: '09:00', startTime: '09:30',
endTime: '10:00', endTime: '10:30',
capacityStr: '1', capacityStr: '1',
}) })
@@ -270,23 +281,14 @@ function removeSlot(slot: EditableSlot) {
// ── Add slot ────────────────────────────────────────────── // ── 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) { 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.startTime = start
addForm.value.endTime = addOneHour(start) addForm.value.endTime = addOneHourCapped(start)
} }
function openAddModal() { 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 showAddModal.value = true
} }
@@ -305,6 +307,10 @@ function submitAdd() {
uni.showToast({ title: '请选择时间', icon: 'none' }) uni.showToast({ title: '请选择时间', icon: 'none' })
return return
} }
if (addForm.value.startTime >= addForm.value.endTime) {
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
return
}
if (isNaN(capacity) || capacity < 1) { if (isNaN(capacity) || capacity < 1) {
uni.showToast({ title: '请填写有效容量', icon: 'none' }) uni.showToast({ title: '请填写有效容量', icon: 'none' })
return return

View File

@@ -28,7 +28,12 @@
</view> </view>
<view class="form-row"> <view class="form-row">
<text class="form-label">开始时间</text> <text class="form-label">开始时间</text>
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value"> <picker
mode="multiSelector"
:range="timePickerRange"
:value="timeToPickerIndex(addForm.startTime)"
@change="(e: any) => addForm.startTime = pickerIndexToTime(e.detail.value)"
>
<view class="picker-display"> <view class="picker-display">
<text class="picker-text">{{ addForm.startTime || '请选择' }}</text> <text class="picker-text">{{ addForm.startTime || '请选择' }}</text>
<text class="picker-arrow"></text> <text class="picker-arrow"></text>
@@ -37,7 +42,12 @@
</view> </view>
<view class="form-row"> <view class="form-row">
<text class="form-label">结束时间</text> <text class="form-label">结束时间</text>
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value"> <picker
mode="multiSelector"
:range="timePickerRange"
:value="timeToPickerIndex(addForm.endTime)"
@change="(e: any) => addForm.endTime = pickerIndexToTime(e.detail.value)"
>
<view class="picker-display"> <view class="picker-display">
<text class="picker-text">{{ addForm.endTime || '请选择' }}</text> <text class="picker-text">{{ addForm.endTime || '请选择' }}</text>
<text class="picker-arrow"></text> <text class="picker-arrow"></text>
@@ -128,7 +138,7 @@
</picker> </picker>
</view> </view>
</view> </view>
<text class="gen-hint">将按默认时间表每天 8:00-22:00每小时一节自动生成所选日期范围内的时段</text> <text class="gen-hint">将按默认时间表8:00-9:00之后从 9:30 起每小时一节至 21:30自动生成所选日期范围内的时段</text>
<view class="action-wrap"> <view class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate"> <view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text> <text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
@@ -145,6 +155,13 @@ import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format' import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared' 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() const adminStore = useAdminStore()
@@ -157,8 +174,8 @@ const slotsLoading = ref(false)
// ── Add slot form ──────────────────────────────────────────────── // ── Add slot form ────────────────────────────────────────────────
const addForm = ref({ const addForm = ref({
date: formatDate(new Date()), date: formatDate(new Date()),
startTime: '09:00', startTime: '09:30',
endTime: '10:00', endTime: '10:30',
capacityStr: '10', capacityStr: '10',
}) })
@@ -168,6 +185,10 @@ async function submitAddSlot() {
uni.showToast({ title: '请填写完整信息', icon: 'none' }) uni.showToast({ title: '请填写完整信息', icon: 'none' })
return return
} }
if (addForm.value.startTime >= addForm.value.endTime) {
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
return
}
const capacity = parseInt(addForm.value.capacityStr, 10) const capacity = parseInt(addForm.value.capacityStr, 10)
submitting.value = true submitting.value = true
try { try {

View File

@@ -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))
}

View File

@@ -51,7 +51,7 @@ describe('SlotGeneratorService', () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
describe('generateSlots', () => { 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() const defaultSlots = getDefaultTimeSlots()
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length * 7 }) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length * 7 })
@@ -64,24 +64,25 @@ describe('SlotGeneratorService', () => {
skipDuplicates: boolean skipDuplicates: boolean
} }
expect(skipDuplicates).toBe(true) expect(skipDuplicates).toBe(true)
// 7 days × 14 slots per day = 98
expect(data).toHaveLength(defaultSlots.length * 7) expect(data).toHaveLength(defaultSlots.length * 7)
expect(count).toBe(defaultSlots.length * 7) expect(count).toBe(defaultSlots.length * 7)
}) })
it('creates 14 slots per day (08:00-22:00 hourly)', async () => { it('creates 13 slots per day (08:00-09:00, then 09:30-21:30 hourly)', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 }) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 })
await service.generateSlots(1) await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as { const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; endTime: string }> data: Array<{ startTime: string; endTime: string }>
} }
expect(data).toHaveLength(14) expect(data).toHaveLength(13)
expect(data[0].startTime).toBe('08:00') expect(data[0].startTime).toBe('08:00')
expect(data[0].endTime).toBe('09:00') expect(data[0].endTime).toBe('09:00')
expect(data[13].startTime).toBe('21:00') expect(data[1].startTime).toBe('09:30')
expect(data[13].endTime).toBe('22:00') 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 () => { 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 () => { 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) await service.generateSlots(1)

View File

@@ -25,7 +25,7 @@ export class SlotGeneratorService {
/** /**
* Generate time slots for the next `daysAhead` days based on the fixed * 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. * Uses `createMany` with `skipDuplicates` so re-runs are safe.
* *
* @returns Number of newly created slots * @returns Number of newly created slots

View File

@@ -15,17 +15,42 @@ export const DEFAULT_SLOT_CAPACITY = 1
/** 自动生成时段的天数范围 */ /** 自动生成时段的天数范围 */
export const SLOT_GENERATION_DAYS = 14 export const SLOT_GENERATION_DAYS = 14
/** 默认排课时间表:每天 08:00-22:00每小时一节课 */ /** 默认排课时间表:第一节 08:00-09:00之后从 09:30 起每小时一节至 21:30 */
export const DEFAULT_SCHEDULE_START_HOUR = 8 export const DEFAULT_SCHEDULE_START_TIME = '08:00'
export const DEFAULT_SCHEDULE_END_HOUR = 22 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) */ /** 生成默认时段列表 (startTime, endTime) */
export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> { export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> {
const slots: Array<{ startTime: string; endTime: string }> = [] const duration = 60
for (let h = DEFAULT_SCHEDULE_START_HOUR; h < DEFAULT_SCHEDULE_END_HOUR; h++) { const slots: Array<{ startTime: string; endTime: string }> = [
const startTime = String(h).padStart(2, '0') + ':00' { startTime: DEFAULT_SCHEDULE_START_TIME, endTime: '09:00' },
const endTime = String(h + 1).padStart(2, '0') + ':00' ]
slots.push({ startTime, endTime }) 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 return slots
} }

View File

@@ -19,8 +19,10 @@ export {
DEFAULT_STUDIO_GALLERY_PHOTOS, DEFAULT_STUDIO_GALLERY_PHOTOS,
DEFAULT_SLOT_CAPACITY, DEFAULT_SLOT_CAPACITY,
SLOT_GENERATION_DAYS, SLOT_GENERATION_DAYS,
DEFAULT_SCHEDULE_START_HOUR, DEFAULT_SCHEDULE_START_TIME,
DEFAULT_SCHEDULE_END_HOUR, DEFAULT_SCHEDULE_END_TIME,
DEFAULT_SCHEDULE_OFFSET_START_TIME,
SCHEDULE_MINUTE_STEP,
getDefaultTimeSlots, getDefaultTimeSlots,
TIME_PERIODS, TIME_PERIODS,
DATE_SELECTOR_DAYS, DATE_SELECTOR_DAYS,