Files
mini-yu/pages/booking/booking.vue
lingxiao865 c5af079d8c first commit
2026-02-10 08:05:03 +08:00

534 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="container">
<!-- 自定义日历选择器 -->
<view class="date-selector">
<view class="calendar-header">
<text class="month-title">{{ currentDateStr }}</text>
</view>
<view class="calendar-weekdays">
<text v-for="day in weekdays" :key="day" class="weekday">{{ day }}</text>
</view>
<view class="calendar-days">
<view v-for="(day, index) in calendarDays" :key="day.dateStr || `placeholder-${index}`" class="calendar-day"
:class="{
'placeholder': day.isPlaceholder,
'selected': day.dateStr === selectedDate,
'today': day.isToday,
'disabled': !day.hasBookings && !day.isPlaceholder,
'has-bookings': day.hasBookings
}" @click="!day.isPlaceholder && selectDate(day)">
<text v-if="!day.isPlaceholder" class="day-number">{{ day.day }}</text>
<text v-if="!day.isPlaceholder && day.bookingCount > 0" class="booking-count">
{{ day.bookingCount }}
</text>
</view>
</view>
</view>
<!-- 时间槽列表 -->
<scroll-view class="timeslots-scroll" scroll-y="true">
<view class="timeslots-section">
<view class="section-title">
{{ selectedDate }} 可选时间段
<text v-if="totalBookingCount > 0" class="total-booking">
(总预约: {{ totalBookingCount }})
</text>
</view>
<t-loading v-if="loading" loading />
<view v-else-if="timeslots.length === 0" class="empty-state">
<t-empty description="暂无可预约时间段" />
</view>
<view v-else class="timeslots-list">
<view v-for="slot in timeslots" :key="slot.id" class="timeslot-card"
:class="{ disabled: !slot.is_active || slot.current_people >= slot.max_people }">
<view class="timeslot-info">
<view class="time-range">
{{ formatTime(slot.start_time) }} - {{ formatTime(slot.end_time) }}
</view>
<view class="slot-status">
<t-tag :theme="slot.current_people >= slot.max_people ? 'danger' : 'success'" size="small">
{{ slot.current_people }}/{{ slot.max_people }}
</t-tag>
</view>
</view>
<t-button t-class="btn-primary" size="small" theme="primary"
:disabled="!slot.is_active || slot.current_people >= slot.max_people"
@click.stop="showBookingModal(slot)">
{{ slot.current_people >= slot.max_people ? '已满' : '预约' }}
</t-button>
</view>
</view>
</view>
</scroll-view>
<!-- 预约弹窗 -->
<t-dialog v-model:visible="showDialog" title="预约确认" cancelBtn="取消"
confirmBtn="确认预约" t-class-confirm="btn-primary" @confirm="confirmBooking"
@cancel="showDialog = false">
<template #content>
<!-- 适配skyline增加type="list" -->
<scroll-view type="list" scroll-y class="long-content">
<view class="booking-dialog">
<view class="dialog-item">
<text class="dialog-label">日期</text>
<text class="dialog-value">{{ selectedDate }}</text>
</view>
<view class="dialog-item">
<text class="dialog-label">时间段</text>
<text class="dialog-value">{{ selectedSlot?.start_time ? formatTime(selectedSlot.start_time) : '' }} - {{
selectedSlot?.end_time ? formatTime(selectedSlot.end_time) : '' }}</text>
</view>
<view class="dialog-item">
<text class="dialog-label">人数</text>
<t-stepper v-model="peopleCount" :min="1"
:max="selectedSlot?.max_people - selectedSlot?.current_people || 1" :disabled="true" size="small" />
</view>
</view>
</scroll-view>
</template>
</t-dialog>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api, type TimeSlot } from '@/utils/api'
// 获取本地时区的日期字符串(东八区)
const getLocalDateStr = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const selectedDate = ref(getLocalDateStr())
const timeslots = ref<TimeSlot[]>([])
const loading = ref(false)
const showDialog = ref(false)
const selectedSlot = ref<TimeSlot | null>(null)
const peopleCount = ref(1)
const notes = ref('')
const isBooking = ref(false) // 防止重复点击
// 日历相关
const currentDate = ref(new Date())
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const dateBookings = ref<Record<string, number>>({})
const dateHasSlots = ref<Record<string, boolean>>({})
// 计算当前月份显示
const currentDateStr = computed(() => {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth() + 1
return `${year}${month}`
})
// 计算总预约人数
const totalBookingCount = computed(() => {
return timeslots.value.reduce((sum, slot) => sum + slot.current_people, 0)
})
// 生成日历数据 - 只显示本月(但保留上个月的占位以对齐星期)
const calendarDays = computed(() => {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
// 获取当月第一天和最后一天
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const today = new Date()
// 获取当月第一天是星期几
const startWeekday = firstDay.getDay()
const days: any[] = []
// 添加上个月的日期占位(用于对齐),但设置为不可点击
for (let i = startWeekday - 1; i >= 0; i--) {
days.push({
day: '',
dateStr: '',
isPlaceholder: true,
isOtherMonth: false,
isToday: false,
bookingCount: 0,
hasBookings: false
})
}
// 获取当月的日期
for (let i = 1; i <= lastDay.getDate(); i++) {
const date = new Date(year, month, i)
// 使用本地时间获取日期部分,避免时区问题
const yearNum = date.getFullYear()
const monthNum = date.getMonth() + 1
const dayNum = date.getDate()
const dateStr = `${yearNum}-${String(monthNum).padStart(2, '0')}-${String(dayNum).padStart(2, '0')}`
// 使用本地时间比较是否为今天
const todayYear = today.getFullYear()
const todayMonth = today.getMonth()
const todayDay = today.getDate()
const isToday = yearNum === todayYear && monthNum - 1 === todayMonth && dayNum === todayDay
days.push({
day: i,
dateStr,
isPlaceholder: false,
isOtherMonth: false,
isToday,
bookingCount: dateBookings.value[dateStr] || 0,
hasBookings: dateHasSlots.value[dateStr] || false
})
}
return days
})
// 格式化日期字符串
const formatDateStr = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const dateStr = `${year}-${month}-${day}`
return dateStr
}
// 检查登录状态
onMounted(() => {
const token = uni.getStorageSync('token')
if (!token) {
uni.redirectTo({ url: '/pages/login/login' })
} else {
loadTimeSlots()
loadDateBookings()
}
})
// 加载日期预约统计
const loadDateBookings = async () => {
try {
const slots = await api.timeslots.getList({ is_active: true })
const bookings: Record<string, number> = {}
const slotsMap: Record<string, boolean> = {}
slots.forEach(slot => {
const dateStr = slot.date.split('T')[0]
bookings[dateStr] = (bookings[dateStr] || 0) + slot.current_people
slotsMap[dateStr] = true
})
dateBookings.value = bookings
dateHasSlots.value = slotsMap
} catch (error) {
console.error('加载日期预约统计失败', error)
}
}
// 加载时间槽
const loadTimeSlots = async () => {
loading.value = true
try {
const slots = await api.timeslots.getList({
date: selectedDate.value,
is_active: true
})
timeslots.value = slots
} catch (error) {
console.error('加载时间槽失败', error)
} finally {
loading.value = false
}
}
// 选择日期
const selectDate = (day: any) => {
selectedDate.value = day.dateStr
loadTimeSlots()
}
// 格式化时间
const formatTime = (timeStr: string) => {
// 直接提取时间部分,避免时区转换问题
const timePart = timeStr.split('T')[1] || timeStr
const timeWithoutZone = timePart.split('+')[0].split('Z')[0]
const [hours, minutes] = timeWithoutZone.split(':')
return `${hours}:${minutes}`
}
// 显示预约弹窗
const showBookingModal = (slot: TimeSlot) => {
// 防止重复点击
if (isBooking.value) {
uni.showToast({
title: '正在处理,请稍候',
icon: 'none'
})
return
}
if (!slot.is_active || slot.current_people >= slot.max_people) {
console.log('时间槽不可用', slot)
uni.showToast({
title: '该时间段已不可用',
icon: 'none'
})
return
}
selectedSlot.value = slot
peopleCount.value = 1
notes.value = ''
showDialog.value = true
console.log('打开弹窗', showDialog.value)
}
// 确认预约
const confirmBooking = async () => {
if (!selectedSlot.value) return
isBooking.value = true
try {
await api.appointments.create(selectedSlot.value.id, peopleCount.value, notes.value)
uni.showToast({
title: '预约成功',
icon: 'success'
})
showDialog.value = false
loadTimeSlots()
loadDateBookings()
} catch (error) {
showDialog.value = false
} finally {
isBooking.value = false
}
}
</script>
<style scoped>
.container {
height: calc(100vh - var(--window-top));
display: flex;
flex-direction: column;
background: #f5f5f5;
overflow: hidden;
}
.date-selector {
flex-shrink: 0;
background: #ffffff;
margin: 24rpx 32rpx;
border-radius: 16rpx;
padding: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.calendar-header {
text-align: center;
margin-bottom: 24rpx;
}
.month-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8rpx;
margin-bottom: 16rpx;
padding: 0 10rpx;
}
.weekday {
font-size: 24rpx;
color: #999;
text-align: center;
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8rpx;
padding: 0 10rpx;
}
.calendar-day {
height: 100rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 8rpx;
cursor: pointer;
transition: all 0.3s ease;
}
.calendar-day.placeholder {
visibility: hidden;
}
.calendar-day.disabled {
opacity: 0.4;
pointer-events: none;
}
.calendar-day.has-bookings {
background: rgba(255, 122, 0, 0.08);
border: 2rpx solid #FF7A00;
}
.calendar-day.today {
background: rgba(255, 122, 0, 0.12);
}
.calendar-day.selected {
background: linear-gradient(135deg, #FF7A00 0%, #FF9500 100%);
}
.calendar-day.selected .day-number {
color: #ffffff;
}
.calendar-day.selected .booking-count {
color: #ffffff;
}
.day-number {
font-size: 28rpx;
color: #333;
margin-bottom: 4rpx;
}
.booking-count {
font-size: 20rpx;
color: #FF7A00;
}
.timeslots-scroll {
flex: 1;
overflow: hidden;
}
.timeslots-section {
padding: 32rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 24rpx;
display: flex;
align-items: center;
}
.total-booking {
font-size: 24rpx;
color: #FF7A00;
margin-left: 16rpx;
font-weight: normal;
}
.empty-state {
padding: 80rpx 0;
}
.timeslots-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.timeslot-card {
background: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.timeslot-card.disabled {
opacity: 0.6;
}
.timeslot-info {
flex: 1;
}
.time-range {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 16rpx;
}
.slot-status {
margin-top: 8rpx;
}
.booking-dialog {
padding: 32rpx 0;
}
.dialog-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.dialog-item:last-child {
border-bottom: none;
}
.dialog-label {
width: 120rpx;
font-size: 28rpx;
color: #666;
}
.dialog-value {
flex: 1;
font-size: 28rpx;
color: #333;
font-weight: 500;
}
</style>
<style>
/* 按钮自定义样式 - 使用全局样式 */
.btn-primary {
background: linear-gradient(135deg, #FF7A00 0%, #FF9500 100%) !important;
border: none !important;
border-radius: 8rpx !important;
color: #FFFFFF !important;
box-shadow: 0 4rpx 12rpx rgba(255, 122, 0, 0.3) !important;
outline: none !important;
}
.btn-primary::after {
border: none !important;
box-shadow: none !important;
}
.btn-primary:active {
background: linear-gradient(135deg, #FF6900 0%, #FF8500 100%) !important;
}
</style>