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) }}
+
+
+
+ {{ 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 @@
-
-
-
- {{ item.label }}
-
-
-
- {{ businessHoursText }}
- {{ options ? `¥${options.unit_price}/半小时` : '加载价格中' }}
-
+
+ {{ item.label }}
-
-
-
+
+
+
+ {{ item.label }}
+ {{ item.day }}
+
+
+
+
+
+ 加载中...
+ {{ loadError }}
+ 暂无场地数据
+
+
+
- {{ item.week }}
- {{ item.day }}
+ {{ slot.start }}
+
+
+
+ {{ now.format('HH:mm') }}
-
-
-
-
-
- {{ durationText }}
- {{ options ? `单场 ¥${options.amount_per_court}` : '按选择时长试算' }}
+
+
+
+
+ {{ selectedCourtCount }}个场地 {{ selectionTimeText }}
+ ¥{{ selectionAmount }}
-
-
-
-
-
-
-
- 正在加载可预约时段
- {{ loadError }}
- 当前日期暂无可预约时段
-
-
- {{ formatTime(item.start_at) }}
- {{ item.available_court_count ? `余 ${item.available_court_count} 场` : '已满' }}
-
-
-
-
-
-
-
- 请先选择开始时间
- 该时段无可预约场地
-
-
- {{ court.name }}
-
-
-
-
-
-
- {{ selectionTitle }}
- {{ selectionSubText }}
-
-
+
+
@@ -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 @@