diff --git a/miniprogram/.env b/miniprogram/.env index 42285b5..e405b71 100644 --- a/miniprogram/.env +++ b/miniprogram/.env @@ -1,2 +1,2 @@ -VITE_ICE_API_BASE_URL=https://8002.frp.chatgqt.top +VITE_ICE_API_BASE_URL=https://venue.test.chatgqt.top VITE_COMPREHENSIVE_API_BASE_URL=https://8002.frp.chatgqt.top \ No newline at end of file diff --git a/miniprogram/src/modules/comprehensive/api/privateBooking.js b/miniprogram/src/modules/comprehensive/api/privateBooking.js index 2593bfd..dd2e852 100644 --- a/miniprogram/src/modules/comprehensive/api/privateBooking.js +++ b/miniprogram/src/modules/comprehensive/api/privateBooking.js @@ -10,12 +10,22 @@ const toQuoteCourt = (backendCourt = {}) => ({ court_type_label: backendCourt.court_type_label || '' }) +const toQuoteItem = (backendItem = {}) => ({ + court_id: backendItem.court_id ?? null, + court_name: backendItem.court_name || '', + start_at: backendItem.start_at || '', + end_at: backendItem.end_at || '', + unit_count: toNumber(backendItem.unit_count), + amount: backendItem.amount || '0.00' +}) + const toPrivateQuote = (backendQuote = {}) => ({ amount: backendQuote.amount || '0.00', unit_count: toNumber(backendQuote.unit_count), start_at: backendQuote.start_at || '', end_at: backendQuote.end_at || '', - courts: toArray(backendQuote.courts).map(toQuoteCourt) + courts: toArray(backendQuote.courts).map(toQuoteCourt), + items: toArray(backendQuote.items).map(toQuoteItem) }) const toPrivateCreateResult = (backendData = {}) => ({ @@ -44,6 +54,34 @@ const toPrivateBookingOptions = (backendData = {}) => ({ time_options: toArray(backendData.time_options).map(toPrivateTimeOption) }) +const toGridCourt = (backendCourt = {}) => ({ + id: backendCourt.id ?? null, + name: backendCourt.name || '' +}) + +const toGridSlot = (backendSlot = {}) => ({ + start: backendSlot.start || '', + end: backendSlot.end || '' +}) + +const toGridOccupation = (backendOcc = {}) => ({ + court_id: backendOcc.court_id ?? null, + start_at: backendOcc.start_at || '', + end_at: backendOcc.end_at || '' +}) + +const toPrivateBookingGrid = (backendData = {}) => ({ + date: backendData.date || '', + court_type: backendData.court_type || '', + court_type_label: backendData.court_type_label || '', + open_time: backendData.open_time || '', + close_time: backendData.close_time || '', + unit_price: backendData.unit_price || '0.00', + courts: toArray(backendData.courts).map(toGridCourt), + time_slots: toArray(backendData.time_slots).map(toGridSlot), + occupations: toArray(backendData.occupations).map(toGridOccupation) +}) + export const privateBookingApi = { getPrivateBookingOptions(params = {}) { return request({ @@ -55,25 +93,33 @@ export const privateBookingApi = { }) }).then(toPrivateBookingOptions) }, + + getPrivateBookingGrid(params = {}) { + return request({ + url: '/api/bookings/private/grid', + data: compactParams({ + date: params.date, + court_type: params.courtType || params.court_type + }) + }).then(toPrivateBookingGrid) + }, + quotePrivateBooking(payload = {}) { return request({ url: '/api/bookings/private/quote', method: 'POST', data: { - court_ids: toArray(payload.court_ids), - start_at: payload.start_at, - end_at: payload.end_at + items: toArray(payload.items) } }).then(toPrivateQuote) }, + createPrivateBooking(payload = {}) { return request({ url: '/api/bookings/private', method: 'POST', data: { - court_ids: toArray(payload.court_ids), - start_at: payload.start_at, - end_at: payload.end_at + items: toArray(payload.items) } }).then(toPrivateCreateResult) } diff --git a/miniprogram/src/pages/comprehensive/private/confirm.vue b/miniprogram/src/pages/comprehensive/private/confirm.vue index 0f01bf5..5642555 100644 --- a/miniprogram/src/pages/comprehensive/private/confirm.vue +++ b/miniprogram/src/pages/comprehensive/private/confirm.vue @@ -2,17 +2,19 @@ {{ courtTypeLabel(payload.court_type) }}包场 - - 预约场地 - {{ quote.courts.map(item => item.name).join('、') }} - - - 预约时间 - {{ formatTimeRange(quote.start_at, quote.end_at) }} - - - 预约时长 - {{ formatDuration(quote.start_at, quote.end_at) }} + + + {{ item.court_name }} + ¥{{ item.amount }} + + + {{ formatTime(item.start_at) }}-{{ formatTime(item.end_at) }} + {{ formatDuration(item.start_at, item.end_at) }} + @@ -47,7 +49,7 @@ import { ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking' import { courtTypeLabel } from '@/modules/comprehensive/utils/status' -import { formatDuration, formatTimeRange } from '@/modules/comprehensive/utils/date' +import { formatDuration, formatTime } from '@/modules/comprehensive/utils/date' const payload = ref({}) const quote = ref(null) @@ -84,6 +86,42 @@ const submit = async () => { font-weight: 900; } +.item-row { + padding: $spacing-sm 0; + border-bottom: 1rpx solid $bg-color-hover; +} + +.item-row:last-child { + border-bottom: none; +} + +.item-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8rpx; +} + +.item-name { + color: $text-color-main; + font-size: 28rpx; + font-weight: 800; +} + +.item-amount { + color: $text-color-main; + font-size: 26rpx; + font-weight: 800; +} + +.item-meta { + display: flex; + align-items: center; + gap: $spacing-md; + color: $text-color-muted; + font-size: 24rpx; +} + .summary-row { min-height: 72rpx; display: flex; diff --git a/miniprogram/src/pages/comprehensive/private/index.vue b/miniprogram/src/pages/comprehensive/private/index.vue index aa712f0..a9a69c5 100644 --- a/miniprogram/src/pages/comprehensive/private/index.vue +++ b/miniprogram/src/pages/comprehensive/private/index.vue @@ -1,102 +1,85 @@ @@ -105,11 +88,11 @@ import { computed, onMounted, ref } from 'vue' import dayjs from 'dayjs' import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking' -import { formatAmount, toAmountNumber } from '@/modules/comprehensive/utils/money' -import { formatTime, todayString } from '@/modules/comprehensive/utils/date' -const ADVANCE_DAYS = 7 -const MAX_DURATION_UNITS = 8 +const HEADER_HEIGHT = 75 +const CELL_HEIGHT = 107 +const TIME_COL_WIDTH = 128 +const COURT_COL_WIDTH = 149 const typeOptions = [ { label: '羽毛球', value: 'badminton' }, @@ -117,342 +100,467 @@ const typeOptions = [ ] const courtType = ref('badminton') -const selectedDate = ref(todayString()) -const durationUnits = ref(2) -const options = ref(null) -const selectedTime = ref(null) -const selectedCourtIds = ref([]) +const selectedDate = ref(dayjs().format('YYYY-MM-DD')) +const grid = ref(null) const isLoading = ref(false) const loadError = ref('') +const selectedCells = ref({}) +const now = ref(dayjs()) let loadRequestId = 0 +const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] const dateOptions = computed(() => { const today = dayjs() - const weekLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] - return Array.from({ length: ADVANCE_DAYS }).map((_, index) => { - const date = today.add(index, 'day') - const week = index === 0 ? '今天' : index === 1 ? '明天' : weekLabels[date.day()] + return Array.from({ length: 7 }).map((_, i) => { + const d = today.add(i, 'day') return { - value: date.format('YYYY-MM-DD'), - week, - day: date.format('MM/DD') + label: i === 0 ? '今天' : i === 1 ? '明天' : weekDays[d.day()], + day: `${d.month() + 1}/${d.date()}`, + value: d.format('YYYY-MM-DD') } }) }) -const timeOptions = computed(() => options.value?.time_options || []) -const availableCourts = computed(() => selectedTime.value?.available_courts || []) -const businessHoursText = computed(() => { - if (!options.value) return '营业时间加载中' - return `营业 ${normalizeClock(options.value.open_time)}-${normalizeClock(options.value.close_time)}` -}) -const durationText = computed(() => { - const minutes = durationUnits.value * 30 - if (minutes % 60 === 0) return `${minutes / 60} 小时` - if (minutes > 60) return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟` - return `${minutes} 分钟` -}) -const canConfirm = computed(() => selectedTime.value && selectedCourtIds.value.length > 0) -const estimatedAmount = computed(() => { - return formatAmount(toAmountNumber(options.value?.amount_per_court) * selectedCourtIds.value.length) -}) -const selectionTitle = computed(() => { - if (!selectedTime.value) return '请选择开始时间' - if (!selectedCourtIds.value.length) return `${formatTime(selectedTime.value.start_at)}-${formatTime(selectedTime.value.end_at)}` - return `${selectedCourtIds.value.length} 个场地 · ¥${estimatedAmount.value}` -}) -const selectionSubText = computed(() => { - if (!selectedTime.value) return '选择时长后展示可预约时间' - if (!selectedCourtIds.value.length) return '请选择至少一个场地' - return `${formatTime(selectedTime.value.start_at)}-${formatTime(selectedTime.value.end_at)} · ${durationText.value}` +const openTime = computed(() => normalizeClock(grid.value?.open_time)) +const closeTime = computed(() => normalizeClock(grid.value?.close_time)) + +const occupationMap = computed(() => { + const map = {} + if (!grid.value) return map + for (const occ of grid.value.occupations) { + if (!map[occ.court_id]) map[occ.court_id] = [] + map[occ.court_id].push(occ) + } + return map }) -onMounted(loadOptions) +const cellStatus = computed(() => { + if (!grid.value) return {} + const status = {} + const isToday = selectedDate.value === dayjs().format('YYYY-MM-DD') + const currentTime = now.value -const loadOptions = async () => { - const requestId = ++loadRequestId - isLoading.value = true - loadError.value = '' - selectedTime.value = null - selectedCourtIds.value = [] - try { - const data = await privateBookingApi.getPrivateBookingOptions({ - date: selectedDate.value, - courtType: courtType.value, - durationUnits: durationUnits.value - }) - if (requestId !== loadRequestId) return - options.value = data - selectFirstAvailableTime() - } catch (error) { - if (requestId !== loadRequestId) return - options.value = null - loadError.value = error?.message || '加载失败,请稍后重试' - } finally { - if (requestId === loadRequestId) { - isLoading.value = false + for (const court of grid.value.courts) { + status[court.id] = {} + const occs = occupationMap.value[court.id] || [] + + for (let si = 0; si < grid.value.time_slots.length; si++) { + const slot = grid.value.time_slots[si] + const slotStart = dayjs(`${selectedDate.value} ${slot.start}`) + const slotEnd = dayjs(`${selectedDate.value} ${slot.end}`) + + if (isToday && slotEnd.isBefore(currentTime)) { + status[court.id][si] = 'past' + continue + } + + const isOccupied = occs.some((occ) => { + const occStart = dayjs(occ.start_at) + const occEnd = dayjs(occ.end_at) + return occStart.isBefore(slotEnd) && occEnd.isAfter(slotStart) + }) + + status[court.id][si] = isOccupied ? 'occupied' : 'available' } } -} + return status +}) -const selectFirstAvailableTime = () => { - const first = timeOptions.value.find((item) => item.available_court_count > 0) - if (first) { - selectTime(first) +const currentTimeLineVisible = computed(() => { + if (!grid.value) return false + return selectedDate.value === dayjs().format('YYYY-MM-DD') +}) + +const currentTimeTop = computed(() => { + if (!grid.value || !currentTimeLineVisible.value) return -1 + const openMin = parseTimeToMinutes(grid.value.open_time) + const closeMin = parseTimeToMinutes(grid.value.close_time) + const currentMin = now.value.hour() * 60 + now.value.minute() + if (currentMin < openMin || currentMin > closeMin) return -1 + return HEADER_HEIGHT + ((currentMin - openMin) / 30) * CELL_HEIGHT +}) + +const selectedCourtCount = computed(() => Object.keys(selectedCells.value).length) +const selectedTotalSlots = computed(() => + Object.values(selectedCells.value).reduce((sum, indices) => sum + indices.length, 0) +) + +const canConfirm = computed(() => selectedCourtCount.value > 0 && selectedTotalSlots.value > 0) + +const selectionTimeText = computed(() => { + const allSlots = [] + for (const indices of Object.values(selectedCells.value)) { + allSlots.push(...indices) + } + if (!allSlots.length) return '' + const sorted = allSlots.sort((a, b) => a - b) + const startSlot = grid.value.time_slots[sorted[0]] + const endSlot = grid.value.time_slots[sorted[sorted.length - 1]] + return `${startSlot.start}-${endSlot.end}` +}) + +const selectionAmount = computed(() => { + const priceVal = parseFloat(grid.value?.unit_price || '0') + return (priceVal * selectedTotalSlots.value).toFixed(2) +}) + +onMounted(loadGrid) + +async function loadGrid() { + const requestId = ++loadRequestId + if (!grid.value) isLoading.value = true + loadError.value = '' + try { + const data = await privateBookingApi.getPrivateBookingGrid({ + date: selectedDate.value, + courtType: courtType.value + }) + if (requestId !== loadRequestId) return + grid.value = data + selectedCells.value = {} + } catch (error) { + if (requestId !== loadRequestId) return + if (!grid.value) loadError.value = error?.message || '加载失败,请稍后重试' + } finally { + if (requestId === loadRequestId) isLoading.value = false } } -const selectType = (type) => { +function selectType(type) { if (courtType.value === type) return courtType.value = type - loadOptions() + loadGrid() } -const selectDate = (date) => { +function selectDate(date) { if (selectedDate.value === date) return selectedDate.value = date - loadOptions() + now.value = dayjs() + loadGrid() } -const decreaseDuration = () => { - if (durationUnits.value <= 1) return - durationUnits.value -= 1 - loadOptions() +function getCellClass(courtId, slotIndex) { + const status = cellStatus.value[courtId]?.[slotIndex] || 'available' + const isSelected = selectedCells.value[courtId]?.includes(slotIndex) + if (isSelected) return { 'cell-selected': true } + return { [`cell-${status}`]: true } } -const increaseDuration = () => { - if (durationUnits.value >= MAX_DURATION_UNITS) return - durationUnits.value += 1 - loadOptions() -} +function toggleCell(courtId, slotIndex) { + const status = cellStatus.value[courtId]?.[slotIndex] + if (status !== 'available') return -const selectTime = (timeOption) => { - if (!timeOption.available_court_count) return - selectedTime.value = timeOption - selectedCourtIds.value = timeOption.available_courts.slice(0, 1).map((court) => court.id) -} + const current = selectedCells.value[courtId] || [] -const toggleCourt = (id) => { - if (selectedCourtIds.value.includes(id)) { - selectedCourtIds.value = selectedCourtIds.value.filter((item) => item !== id) + if (current.includes(slotIndex)) { + const next = current.filter((i) => i !== slotIndex) + if (next.length === 0) { + const next2 = { ...selectedCells.value } + delete next2[courtId] + selectedCells.value = next2 + return + } + const sorted = next.sort((a, b) => a - b) + const isContiguous = sorted.every((val, idx) => idx === 0 || val === sorted[idx - 1] + 1) + if (isContiguous) { + selectedCells.value = { ...selectedCells.value, [courtId]: sorted } + } else { + uni.showToast({ title: '不能取消中间时段', icon: 'none' }) + } + return + } + + if (current.length === 0) { + selectedCells.value = { ...selectedCells.value, [courtId]: [slotIndex] } + return + } + + const minIdx = Math.min(...current) + const maxIdx = Math.max(...current) + + if (slotIndex === minIdx - 1 || slotIndex === maxIdx + 1) { + selectedCells.value = { + ...selectedCells.value, + [courtId]: [...current, slotIndex].sort((a, b) => a - b) + } } else { - selectedCourtIds.value = [...selectedCourtIds.value, id] + selectedCells.value = { ...selectedCells.value, [courtId]: [slotIndex] } } } -const normalizeClock = (value) => { +function goConfirm() { + if (!canConfirm.value) return + + const items = Object.entries(selectedCells.value).map(([courtIdStr, slotIndices]) => { + const courtId = parseInt(courtIdStr) + const sorted = [...slotIndices].sort((a, b) => a - b) + const startSlot = grid.value.time_slots[sorted[0]] + const endSlot = grid.value.time_slots[sorted[sorted.length - 1]] + return { + court_id: courtId, + start_at: buildDateTime(selectedDate.value, startSlot.start), + end_at: buildDateTime(selectedDate.value, endSlot.end) + } + }) + + const payload = encodeURIComponent(JSON.stringify({ + court_type: courtType.value, + items + })) + uni.navigateTo({ url: `/pages/comprehensive/private/confirm?payload=${payload}` }) +} + +function buildDateTime(date, time) { + return `${date}T${time}:00${dayjs().format('Z')}` +} + +function parseTimeToMinutes(timeStr) { + if (!timeStr) return 0 + const parts = timeStr.split(':') + return parseInt(parts[0]) * 60 + parseInt(parts[1]) +} + +function normalizeClock(value) { if (!value) return '--:--' return value.slice(0, 5) } -const goConfirm = () => { - if (!canConfirm.value) return - const payload = encodeURIComponent(JSON.stringify({ - court_type: courtType.value, - court_ids: selectedCourtIds.value, - start_at: selectedTime.value.start_at, - end_at: selectedTime.value.end_at - })) - uni.navigateTo({ url: `/pages/comprehensive/private/confirm?payload=${payload}` }) -} +defineExpose({ + refresh() { + now.value = dayjs() + loadGrid() + } +}) diff --git a/miniprogram/src/pages/ice/private/index.vue b/miniprogram/src/pages/ice/private/index.vue index 605aadc..52444dd 100644 --- a/miniprogram/src/pages/ice/private/index.vue +++ b/miniprogram/src/pages/ice/private/index.vue @@ -9,6 +9,9 @@