Files
mini-yu/pages/booking/booking.vue

534 lines
13 KiB
Vue
Raw Normal View History

2026-02-10 08:05:03 +08:00
<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>