first commit
This commit is contained in:
237
pages/appointments/appointments.vue
Normal file
237
pages/appointments/appointments.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<!-- 状态筛选 -->
|
||||
<view class="tabs">
|
||||
<t-tabs :value="activeTab" @change="onTabChange">
|
||||
<t-tab-panel value="all" label="全部" />
|
||||
<t-tab-panel value="pending" label="待确认" />
|
||||
<t-tab-panel value="confirmed" label="已确认" />
|
||||
<t-tab-panel value="completed" label="已完成" />
|
||||
<t-tab-panel value="cancelled" label="已取消" />
|
||||
</t-tabs>
|
||||
</view>
|
||||
|
||||
<!-- 预约列表 -->
|
||||
<view class="appointments-list">
|
||||
<t-loading v-if="loading" loading />
|
||||
<view v-else-if="appointments.length === 0" class="empty-state">
|
||||
<t-empty description="暂无预约记录" />
|
||||
</view>
|
||||
<view v-else>
|
||||
<view v-for="appointment in appointments" :key="appointment.id" class="appointment-card">
|
||||
<view class="appointment-header">
|
||||
<view class="appointment-status">
|
||||
<t-tag :theme="getStatusTheme(appointment.status)" size="small">
|
||||
{{ getStatusText(appointment.status) }}
|
||||
</t-tag>
|
||||
</view>
|
||||
<view class="appointment-date">
|
||||
{{ formatDate(appointment.created_at) }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="appointment-body">
|
||||
|
||||
<view class="appointment-row">
|
||||
<text class="row-label">时间段:</text>
|
||||
<text class="row-value">
|
||||
{{ appointment.time_slot ? (appointment.time_slot.date).split('T')[0] : '' }}
|
||||
{{ appointment.time_slot ? formatTime(appointment.time_slot.start_time) : '' }} -
|
||||
{{ appointment.time_slot ? formatTime(appointment.time_slot.end_time) : '' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="appointment-row">
|
||||
<text class="row-label">人数:</text>
|
||||
<text class="row-value">{{ appointment.people_count }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="appointment-footer" v-if="appointment.status === 'pending'">
|
||||
<t-button class="btn-outline" size="small" theme="danger" variant="outline"
|
||||
@click="cancelAppointment(appointment)">
|
||||
取消预约
|
||||
</t-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, type Appointment } from '@/utils/api'
|
||||
|
||||
const activeTab = ref('all')
|
||||
const appointments = ref<Appointment[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadAppointments()
|
||||
})
|
||||
|
||||
// 加载预约列表
|
||||
const loadAppointments = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (activeTab.value !== 'all') {
|
||||
params.status = activeTab.value
|
||||
}
|
||||
appointments.value = await api.appointments.getList(params)
|
||||
// 调试:打印第一个预约的数据
|
||||
} catch (error) {
|
||||
console.error('加载预约失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换标签
|
||||
const onTabChange = (value: any) => {
|
||||
// TDesign 可能传递对象或字符串
|
||||
const tabValue = typeof value === 'string' ? value : (value?.value || 'all')
|
||||
activeTab.value = tabValue
|
||||
loadAppointments()
|
||||
}
|
||||
|
||||
// 获取状态主题
|
||||
const getStatusTheme = (status: string) => {
|
||||
const themes: Record<string, any> = {
|
||||
pending: 'warning',
|
||||
confirmed: 'success',
|
||||
completed: 'primary',
|
||||
cancelled: 'default'
|
||||
}
|
||||
return themes[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
pending: '待确认',
|
||||
confirmed: '已确认',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return dateStr.replace('T', ' ').split('+')[0];
|
||||
} catch (error) {
|
||||
console.error('Date format error:', error, dateStr)
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
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 cancelAppointment = (appointment: Appointment) => {
|
||||
uni.showModal({
|
||||
title: '确认取消',
|
||||
content: '确定要取消这个预约吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await api.appointments.cancel(appointment.id)
|
||||
uni.showToast({
|
||||
title: '取消成功',
|
||||
icon: 'success'
|
||||
})
|
||||
loadAppointments()
|
||||
} catch (error) {
|
||||
console.error('取消预约失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
height: calc(100vh - var(--window-top));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F8F9FA;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.appointments-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20rpx 32rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.appointment-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.appointment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.appointment-date {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.appointment-body {
|
||||
padding: 24rpx 0;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.appointment-row {
|
||||
display: flex;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.appointment-footer {
|
||||
padding-top: 24rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
533
pages/booking/booking.vue
Normal file
533
pages/booking/booking.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<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>
|
||||
183
pages/index/index.vue
Normal file
183
pages/index/index.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
|
||||
<!-- 轮播图 -->
|
||||
<view class="swiper-container">
|
||||
<swiper
|
||||
class="swiper"
|
||||
:indicator-dots="true"
|
||||
:autoplay="true"
|
||||
:interval="3000"
|
||||
:duration="500"
|
||||
indicator-color="rgba(255, 255, 255, 0.5)"
|
||||
indicator-active-color="#FF7A00"
|
||||
>
|
||||
<swiper-item v-for="(item, index) in banners" :key="index" class="swiper-item">
|
||||
<image :src="item.image" class="banner-image" mode="aspectFill" lazy-load />
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
|
||||
<!-- 九宫格导航 -->
|
||||
<view class="grid-scroll">
|
||||
<view class="grid-container">
|
||||
<view
|
||||
v-for="(item, index) in menuItems"
|
||||
:key="item.path"
|
||||
class="grid-item"
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<view class="grid-icon">{{ item.icon }}</view>
|
||||
<text class="grid-text">{{ item.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- FAB 悬浮退出登录按钮 -->
|
||||
<t-fab
|
||||
icon="caret-right"
|
||||
aria-label="退出"
|
||||
@click="logout"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, type User } from '@/utils/api'
|
||||
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
// 轮播图数据
|
||||
const banners = ref([
|
||||
{ image: 'https://picsum.photos/800/400?random=1', title: '专业美容服务' },
|
||||
{ image: 'https://picsum.photos/800/400?random=2', title: '预约更便捷' },
|
||||
{ image: 'https://picsum.photos/800/400?random=3', title: '优惠活动' }
|
||||
])
|
||||
|
||||
// 九宫格菜单数据
|
||||
const menuItems = ref([
|
||||
{ icon: '📅', text: '我要预约', path: '/pages/booking/booking' },
|
||||
{ icon: '📋', text: '我的预约', path: '/pages/appointments/appointments' }
|
||||
])
|
||||
|
||||
// 检查登录状态
|
||||
onMounted(() => {
|
||||
const token = uni.getStorageSync('token')
|
||||
if (!token) {
|
||||
uni.redirectTo({ url: '/pages/login/login' })
|
||||
} else {
|
||||
user.value = uni.getStorageSync('user')
|
||||
}
|
||||
})
|
||||
|
||||
// 导航跳转
|
||||
const navigateTo = (path: string) => {
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.removeStorageSync('token')
|
||||
uni.removeStorageSync('user')
|
||||
uni.redirectTo({ url: '/pages/login/login' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
height: calc(100vh - var(--window-top));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #FF7A00 0%, #FF9500 100%);
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 轮播图样式 */
|
||||
.swiper-container {
|
||||
flex-shrink: 0;
|
||||
margin: 24rpx 32rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.swiper {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
}
|
||||
|
||||
.swiper-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #FF7A00 0%, #FF9500 100%);
|
||||
}
|
||||
|
||||
/* 九宫格样式 */
|
||||
.grid-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24rpx;
|
||||
padding: 0 32rpx;
|
||||
width: 100%;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 40rpx 20rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.grid-item:active {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.grid-icon {
|
||||
font-size: 64rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.grid-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
264
pages/login/login.vue
Normal file
264
pages/login/login.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<view class="login-container">
|
||||
<view class="login-header">
|
||||
<text class="title">欢迎使用预约系统</text>
|
||||
<text class="subtitle">请登录或注册</text>
|
||||
</view>
|
||||
|
||||
<view class="login-form">
|
||||
<!-- 手机号输入 -->
|
||||
<view class="form-item">
|
||||
<t-input v-model:value="phone" placeholder="请输入手机号" type="number" :maxlength="11" clearable>
|
||||
<template #prefixIcon>
|
||||
<text class="prefix-icon">📱</text>
|
||||
</template>
|
||||
</t-input>
|
||||
</view>
|
||||
|
||||
<!-- 验证码登录 -->
|
||||
<view class="form-item" v-if="loginType === 'code'">
|
||||
<t-input v-model:value="code" placeholder="请输入验证码" type="number" :maxlength="6" clearable>
|
||||
<template #prefixIcon>
|
||||
<text class="prefix-icon">🔐</text>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<t-button size="small" variant="text" :disabled="codeDisabled" @click="sendCode">
|
||||
{{ codeButtonText }}
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
</view>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<view class="form-actions">
|
||||
<t-button v-if="loginType === 'code'" t-class="btn-primary" theme="primary" size="large" block :loading="loading"
|
||||
@click="codeLogin">
|
||||
验证码登录
|
||||
</t-button>
|
||||
|
||||
<t-button v-else t-class="btn-primary" theme="primary" size="large" block :loading="loading" @click="oneClickLogin">
|
||||
一键登录
|
||||
</t-button>
|
||||
</view>
|
||||
|
||||
<view class="form-switch">
|
||||
<text @click="toggleLoginType">
|
||||
{{ loginType === 'code' ? '使用一键登录' : '使用验证码登录' }}
|
||||
</text>
|
||||
<text class="register-link" @click="goToRegister">注册新账号</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
const phone = ref('13777777777')
|
||||
const code = ref('')
|
||||
const loginType = ref<'code' | 'one-click'>('one-click')
|
||||
const loading = ref(false)
|
||||
const codeDisabled = ref(false)
|
||||
const countdown = ref(0)
|
||||
|
||||
const codeButtonText = ref('发送验证码')
|
||||
|
||||
// 切换登录方式
|
||||
const toggleLoginType = () => {
|
||||
loginType.value = loginType.value === 'code' ? 'one-click' : 'code'
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
const sendCode = async () => {
|
||||
console.log(phone.value);
|
||||
if (!phone.value || phone.value.length !== 11) {
|
||||
uni.showToast({
|
||||
title: '请输入正确的手机号',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.auth.sendCode('+86' + phone.value)
|
||||
uni.showToast({
|
||||
title: '验证码已发送',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 开始倒计时
|
||||
codeDisabled.value = true
|
||||
countdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--
|
||||
codeButtonText.value = `${countdown.value}秒后重发`
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
codeDisabled.value = false
|
||||
codeButtonText.value = '发送验证码'
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error('发送验证码失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码登录
|
||||
const codeLogin = async () => {
|
||||
if (!phone.value || phone.value.length !== 11) {
|
||||
uni.showToast({
|
||||
title: '请输入正确的手机号',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!code.value || code.value.length !== 6) {
|
||||
uni.showToast({
|
||||
title: '请输入验证码',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.auth.verificationLogin('+86' + phone.value, code.value)
|
||||
uni.setStorageSync('token', res.token)
|
||||
uni.setStorageSync('user', res.user)
|
||||
|
||||
uni.showToast({
|
||||
title: '登录成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 直接跳转到首页
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
} catch (error) {
|
||||
console.error('登录失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 一键登录
|
||||
const oneClickLogin = async () => {
|
||||
if (!phone.value || phone.value.length !== 11) {
|
||||
uni.showToast({
|
||||
title: '请输入正确的手机号',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.auth.oneClickLogin('+86' + phone.value)
|
||||
uni.setStorageSync('token', res.token)
|
||||
uni.setStorageSync('user', res.user)
|
||||
|
||||
uni.showToast({
|
||||
title: '登录成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 直接跳转到首页
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
} catch (error) {
|
||||
console.error('登录失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到注册页
|
||||
const goToRegister = () => {
|
||||
uni.navigateTo({ url: '/pages/register/register' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #FF7A00 0%, #FF9500 100%);
|
||||
padding: 80rpx 40rpx;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 100rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 60rpx 40rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.prefix-icon {
|
||||
font-size: 32rpx;
|
||||
color: #FF7A00;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 60rpx;
|
||||
}
|
||||
|
||||
.form-switch {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form-switch text {
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
color: #FF7A00 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
</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>
|
||||
170
pages/register/register.vue
Normal file
170
pages/register/register.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<view class="register-container">
|
||||
<view class="register-header">
|
||||
<text class="title">注册账号</text>
|
||||
<text class="subtitle">创建您的预约系统账号</text>
|
||||
</view>
|
||||
|
||||
<view class="register-form">
|
||||
<view class="form-item">
|
||||
<t-input
|
||||
v-model:value="phone"
|
||||
placeholder="请输入手机号"
|
||||
type="number"
|
||||
:maxlength="11"
|
||||
clearable
|
||||
>
|
||||
<template #prefixIcon>
|
||||
<text class="prefix-icon">📱</text>
|
||||
</template>
|
||||
</t-input>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<t-input
|
||||
v-model:value="nickname"
|
||||
placeholder="请输入昵称"
|
||||
:maxlength="20"
|
||||
clearable
|
||||
>
|
||||
<template #prefixIcon>
|
||||
<text class="prefix-icon">👤</text>
|
||||
</template>
|
||||
</t-input>
|
||||
</view>
|
||||
|
||||
<view class="form-actions">
|
||||
<t-button
|
||||
class="btn-primary"
|
||||
theme="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
@click="register"
|
||||
>
|
||||
注册
|
||||
</t-button>
|
||||
</view>
|
||||
|
||||
<view class="form-switch">
|
||||
<text @click="goToLogin">已有账号?去登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
const phone = ref('')
|
||||
const nickname = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const register = async () => {
|
||||
if (!phone.value || String(phone.value).length !== 11) {
|
||||
uni.showToast({
|
||||
title: '请输入正确的手机号',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!nickname.value || nickname.value.trim().length === 0) {
|
||||
uni.showToast({
|
||||
title: '请输入昵称',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.auth.register('+86' + phone.value, nickname.value.trim())
|
||||
uni.setStorageSync('token', res.token)
|
||||
uni.setStorageSync('user', res.user)
|
||||
|
||||
uni.showToast({
|
||||
title: '注册成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 直接跳转到首页
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
} catch (error) {
|
||||
console.error('注册失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #FF7A00 0%, #FF9500 100%);
|
||||
padding: 80rpx 40rpx;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 100rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.register-form {
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 60rpx 40rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.prefix-icon {
|
||||
font-size: 32rpx;
|
||||
color: #FF7A00;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 60rpx;
|
||||
}
|
||||
|
||||
.form-switch {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: #FF7A00;
|
||||
}
|
||||
|
||||
.form-switch text {
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
/* 按钮自定义样式 */
|
||||
.btn-primary :deep(.t-button) {
|
||||
background: linear-gradient(135deg, #FF7A00 0%, #FF9500 100%) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 122, 0, 0.3);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user