269 lines
7.6 KiB
Plaintext
269 lines
7.6 KiB
Plaintext
// ============================================================
|
||
// 居家上门服务预约时间工具
|
||
// 负责:动态日期生成、常用/自定义时间段生成、时间合法性校验
|
||
// 约束:所有计算基于真实当前时间,不写死任何展示文本
|
||
// ============================================================
|
||
|
||
export const BOOKING_DAYS_LIMIT = 7
|
||
export const MIN_ADVANCE_MINUTES = 30
|
||
export const CUSTOM_SLOT_DURATION_MINUTES = 60
|
||
export const CUSTOM_SLOT_STEP_MINUTES = 30
|
||
|
||
export const PRESET_SLOT_RANGES = [
|
||
'09:00-10:00',
|
||
'10:00-11:00',
|
||
'14:00-15:00',
|
||
'15:00-16:00',
|
||
'18:00-19:00'
|
||
]
|
||
|
||
export type BookingDayOptionType = {
|
||
id: string
|
||
dateKey: string
|
||
label: string
|
||
dateText: string
|
||
weekday: string
|
||
timestamp: number
|
||
available: boolean
|
||
disabledReason: string
|
||
}
|
||
|
||
export type BookingTimeSlotType = {
|
||
id: string
|
||
label: string
|
||
startAt: number
|
||
endAt: number
|
||
source: string
|
||
available: boolean
|
||
disabledReason: string
|
||
}
|
||
|
||
export type BookingValidationResultType = {
|
||
valid: boolean
|
||
message: string
|
||
}
|
||
|
||
const WEEKDAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
|
||
function padLeft(num: number, length: number): string {
|
||
let result = String(num)
|
||
while (result.length < length) {
|
||
result = '0' + result
|
||
}
|
||
return result
|
||
}
|
||
|
||
function parseDateKey(dateKey: string): { year: number, month: number, day: number } | null {
|
||
if (dateKey.length != 10) {
|
||
return null
|
||
}
|
||
const year = parseInt(dateKey.substring(0, 4))
|
||
const month = parseInt(dateKey.substring(5, 7)) - 1
|
||
const day = parseInt(dateKey.substring(8, 10))
|
||
if (isNaN(year) || isNaN(month) || isNaN(day)) {
|
||
return null
|
||
}
|
||
return { year, month, day }
|
||
}
|
||
|
||
function parseTimeRange(range: string): { startH: number, startM: number, endH: number, endM: number } | null {
|
||
const parts = range.split('-')
|
||
if (parts.length != 2) {
|
||
return null
|
||
}
|
||
const startParts = parts[0].split(':')
|
||
const endParts = parts[1].split(':')
|
||
if (startParts.length != 2 || endParts.length != 2) {
|
||
return null
|
||
}
|
||
const startH = parseInt(startParts[0])
|
||
const startM = parseInt(startParts[1])
|
||
const endH = parseInt(endParts[0])
|
||
const endM = parseInt(endParts[1])
|
||
if (isNaN(startH) || isNaN(startM) || isNaN(endH) || isNaN(endM)) {
|
||
return null
|
||
}
|
||
return { startH, startM, endH, endM }
|
||
}
|
||
|
||
export function buildBookingDays(now: Date): Array<BookingDayOptionType> {
|
||
const result: Array<BookingDayOptionType> = []
|
||
const base = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0)
|
||
|
||
for (let i = 0; i < BOOKING_DAYS_LIMIT; i++) {
|
||
const d = new Date(base.getTime() + i * 24 * 60 * 60 * 1000)
|
||
const year = d.getFullYear()
|
||
const month = d.getMonth() + 1
|
||
const day = d.getDate()
|
||
const weekdayIndex = d.getDay()
|
||
|
||
const dateKey = year + '-' + padLeft(month, 2) + '-' + padLeft(day, 2)
|
||
let label = ''
|
||
if (i == 0) {
|
||
label = '今天'
|
||
} else if (i == 1) {
|
||
label = '明天'
|
||
} else if (i == 2) {
|
||
label = '后天'
|
||
} else {
|
||
label = WEEKDAY_LABELS[weekdayIndex]
|
||
}
|
||
|
||
const dateText = padLeft(month, 2) + '月' + padLeft(day, 2) + '日'
|
||
const weekday = WEEKDAY_LABELS[weekdayIndex]
|
||
|
||
result.push({
|
||
id: 'day-' + String(i),
|
||
dateKey,
|
||
label,
|
||
dateText,
|
||
weekday,
|
||
timestamp: d.getTime(),
|
||
available: true,
|
||
disabledReason: ''
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
export function buildPresetSlots(selectedDateKey: string, now: Date): Array<BookingTimeSlotType> {
|
||
const result: Array<BookingTimeSlotType> = []
|
||
const parsed = parseDateKey(selectedDateKey)
|
||
if (parsed == null) {
|
||
return result
|
||
}
|
||
|
||
const minStartTime = now.getTime() + MIN_ADVANCE_MINUTES * 60 * 1000
|
||
|
||
for (let i = 0; i < PRESET_SLOT_RANGES.length; i++) {
|
||
const range = PRESET_SLOT_RANGES[i]
|
||
const time = parseTimeRange(range)
|
||
if (time == null) {
|
||
continue
|
||
}
|
||
|
||
const startDate = new Date(parsed.year, parsed.month, parsed.day, time.startH, time.startM, 0, 0)
|
||
const endDate = new Date(parsed.year, parsed.month, parsed.day, time.endH, time.endM, 0, 0)
|
||
const startAt = startDate.getTime()
|
||
const endAt = endDate.getTime()
|
||
|
||
const available = startAt >= minStartTime
|
||
result.push({
|
||
id: 'preset-' + String(i),
|
||
label: range,
|
||
startAt,
|
||
endAt,
|
||
source: 'preset',
|
||
available,
|
||
disabledReason: available ? '' : '该时段距离当前不足30分钟,无法保证服务人员按时到达'
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
export function buildCustomSlots(selectedDateKey: string, now: Date): Array<BookingTimeSlotType> {
|
||
const result: Array<BookingTimeSlotType> = []
|
||
const parsed = parseDateKey(selectedDateKey)
|
||
if (parsed == null) {
|
||
return result
|
||
}
|
||
|
||
const minStartTime = now.getTime() + MIN_ADVANCE_MINUTES * 60 * 1000
|
||
|
||
for (let h = 0; h < 24; h++) {
|
||
for (let m = 0; m < 60; m += CUSTOM_SLOT_STEP_MINUTES) {
|
||
const startDate = new Date(parsed.year, parsed.month, parsed.day, h, m, 0, 0)
|
||
const endDate = new Date(parsed.year, parsed.month, parsed.day, h, m + CUSTOM_SLOT_DURATION_MINUTES, 0, 0)
|
||
|
||
const startAt = startDate.getTime()
|
||
const endAt = endDate.getTime()
|
||
const available = startAt >= minStartTime
|
||
|
||
const startLabel = padLeft(h, 2) + ':' + padLeft(m, 2)
|
||
const endLabel = padLeft(endDate.getHours(), 2) + ':' + padLeft(endDate.getMinutes(), 2)
|
||
|
||
result.push({
|
||
id: 'custom-' + startLabel + '-' + endLabel,
|
||
label: startLabel + '-' + endLabel,
|
||
startAt,
|
||
endAt,
|
||
source: 'custom',
|
||
available,
|
||
disabledReason: available ? '' : '该时段距离当前不足30分钟,无法保证服务人员按时到达'
|
||
})
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
export function hasAnyAvailableSlots(dateKey: string, now: Date): boolean {
|
||
const presets = buildPresetSlots(dateKey, now)
|
||
for (let i = 0; i < presets.length; i++) {
|
||
if (presets[i].available) {
|
||
return true
|
||
}
|
||
}
|
||
const customs = buildCustomSlots(dateKey, now)
|
||
for (let i = 0; i < customs.length; i++) {
|
||
if (customs[i].available) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
export function validateSelectedAppointmentTime(
|
||
selectedDay: BookingDayOptionType | null,
|
||
selectedSlot: BookingTimeSlotType | null,
|
||
now: Date
|
||
): BookingValidationResultType {
|
||
if (selectedDay == null) {
|
||
return { valid: false, message: '请选择预约日期' }
|
||
}
|
||
if (selectedSlot == null) {
|
||
return { valid: false, message: '请选择预约时间段' }
|
||
}
|
||
|
||
const slotStartDate = new Date(selectedSlot.startAt)
|
||
const dayDate = new Date(selectedDay.timestamp)
|
||
const sameDay = slotStartDate.getFullYear() == dayDate.getFullYear()
|
||
&& slotStartDate.getMonth() == dayDate.getMonth()
|
||
&& slotStartDate.getDate() == dayDate.getDate()
|
||
if (!sameDay) {
|
||
return { valid: false, message: '所选时间段不属于当前选中的日期' }
|
||
}
|
||
|
||
const minStartTime = now.getTime() + MIN_ADVANCE_MINUTES * 60 * 1000
|
||
if (selectedSlot.startAt < minStartTime) {
|
||
return { valid: false, message: '当前选择的上门时间已失效,请重新选择稍晚的时间段' }
|
||
}
|
||
|
||
const maxDate = new Date(now.getTime() + BOOKING_DAYS_LIMIT * 24 * 60 * 60 * 1000)
|
||
const maxDateStart = new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate(), 0, 0, 0, 0)
|
||
if (selectedDay.timestamp > maxDateStart.getTime()) {
|
||
return { valid: false, message: '预约日期超出可预约范围' }
|
||
}
|
||
|
||
return { valid: true, message: '' }
|
||
}
|
||
|
||
export function formatAppointmentDisplayText(
|
||
day: BookingDayOptionType | null,
|
||
slot: BookingTimeSlotType | null
|
||
): string {
|
||
if (day == null || slot == null) {
|
||
return '请选择上门时间'
|
||
}
|
||
return day.label + ' ' + day.dateText + ' ' + day.weekday + ' ' + slot.label
|
||
}
|
||
|
||
export function formatStandardAppointmentTime(
|
||
day: BookingDayOptionType | null,
|
||
slot: BookingTimeSlotType | null
|
||
): string {
|
||
if (day == null || slot == null) {
|
||
return ''
|
||
}
|
||
return new Date(slot.startAt).toISOString()
|
||
}
|