first commit

This commit is contained in:
lingxiao865
2026-02-10 08:05:03 +08:00
commit c5af079d8c
1094 changed files with 97530 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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>