mi-assessment/uniapp/pages/planner/book/index.vue
2026-02-20 14:57:43 +08:00

1044 lines
23 KiB
Vue
Raw 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.

<script setup>
/**
* 规划预约页面
*
* 功能:
* - 规划师信息展示
* - 日期时间选择
* - 表单填写:姓名、手机号、年级、成绩(根据年级动态显示)
* - 支付预约
* - 预约成功弹窗
*/
import { ref, computed, watch, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { useAuth } from '@/composables/useAuth.js'
import { usePayment } from '@/composables/usePayment.js'
import { getPlannerDetail, getAvailableTime } from '@/api/planner.js'
import { isPhone } from '@/utils/validate.js'
const userStore = useUserStore()
const { checkLogin } = useAuth()
const { processPayment } = usePayment()
// 页面参数
const plannerId = ref(0)
const plannerName = ref('')
// 规划师信息
const plannerInfo = ref({
id: 0,
name: '',
avatar: '',
intro: '',
price: 0
})
// 日期选择
const selectedDate = ref('')
const dateList = ref([])
const currentDateIndex = ref(0)
// 时间选择
const selectedTime = ref('')
const timeList = ref([])
const timeLoading = ref(false)
// 表单数据
const formData = ref({
name: '',
phone: '',
grade: '',
score: ''
})
// 年级选项
const gradeOptions = [
'小学一年级', '小学二年级', '小学三年级', '小学四年级', '小学五年级', '小学六年级',
'初一', '初二', '初三',
'高一', '高二', '高三',
'大一', '大二', '大三', '大四',
'研究生', '其他'
]
const gradeIndex = ref(-1)
// 需要显示成绩的年级
const gradesWithScore = ['初一', '初二', '初三', '高一', '高二', '高三']
// 是否显示成绩字段
const showScoreField = computed(() => {
return gradesWithScore.includes(formData.value.grade)
})
// 页面加载状态
const pageLoading = ref(true)
const submitting = ref(false)
// 预约成功弹窗
const showSuccessPopup = ref(false)
const bookingInfo = ref({
date: '',
time: '',
plannerName: ''
})
/**
* 生成日期列表未来7天
*/
function generateDateList() {
const list = []
const today = new Date()
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
for (let i = 0; i < 7; i++) {
const date = new Date(today)
date.setDate(today.getDate() + i)
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}`
let label = ''
if (i === 0) {
label = '今天'
} else if (i === 1) {
label = '明天'
} else {
label = weekDays[date.getDay()]
}
list.push({
date: dateStr,
label: label,
day: `${month}/${day}`
})
}
dateList.value = list
if (list.length > 0) {
selectedDate.value = list[0].date
}
}
/**
* 加载规划师详情
*/
async function loadPlannerDetail() {
try {
const res = await getPlannerDetail(plannerId.value)
if (res && res.code === 0 && res.data) {
plannerInfo.value = {
id: res.data.id || plannerId.value,
name: res.data.name || plannerName.value || '规划师',
avatar: res.data.avatar || res.data.photo || '',
intro: res.data.intro || res.data.introduction || '',
price: res.data.price || 0
}
} else {
// 使用传入的参数
plannerInfo.value.name = plannerName.value || '规划师'
}
} catch (error) {
console.error('加载规划师详情失败:', error)
plannerInfo.value.name = plannerName.value || '规划师'
}
}
/**
* 加载可预约时间
*/
async function loadAvailableTime() {
if (!selectedDate.value) return
timeLoading.value = true
timeList.value = []
selectedTime.value = ''
try {
const res = await getAvailableTime(plannerId.value, selectedDate.value)
if (res && res.code === 0 && res.data) {
// 支持多种数据格式
if (Array.isArray(res.data)) {
timeList.value = res.data.map(item => {
if (typeof item === 'string') {
return { time: item, available: true }
}
return {
time: item.time || item.timeSlot || '',
available: item.available !== false
}
})
} else if (res.data.list && Array.isArray(res.data.list)) {
timeList.value = res.data.list.map(item => ({
time: item.time || item.timeSlot || '',
available: item.available !== false
}))
}
}
// 如果没有数据,生成默认时间段
if (timeList.value.length === 0) {
timeList.value = generateDefaultTimeSlots()
}
} catch (error) {
console.error('加载可预约时间失败:', error)
// 使用默认时间段
timeList.value = generateDefaultTimeSlots()
} finally {
timeLoading.value = false
}
}
/**
* 生成默认时间段
*/
function generateDefaultTimeSlots() {
const slots = []
const times = [
'09:00', '10:00', '11:00',
'14:00', '15:00', '16:00', '17:00',
'19:00', '20:00'
]
times.forEach(time => {
slots.push({ time, available: true })
})
return slots
}
/**
* 选择日期
*/
function selectDate(index) {
currentDateIndex.value = index
selectedDate.value = dateList.value[index].date
}
/**
* 选择时间
*/
function selectTime(item) {
if (!item.available) return
selectedTime.value = item.time
}
/**
* 年级选择
*/
function onGradeChange(e) {
gradeIndex.value = e.detail.value
formData.value.grade = gradeOptions[e.detail.value]
// 切换年级时清空成绩
if (!showScoreField.value) {
formData.value.score = ''
}
}
/**
* 表单是否填写完整
*/
const isFormComplete = computed(() => {
const baseComplete = (
selectedDate.value !== '' &&
selectedTime.value !== '' &&
formData.value.name.trim() !== '' &&
formData.value.phone.trim() !== '' &&
formData.value.grade !== ''
)
// 如果需要显示成绩字段,则成绩也必填
if (showScoreField.value) {
return baseComplete && formData.value.score.trim() !== ''
}
return baseComplete
})
/**
* 验证表单
*/
function validateForm() {
if (!selectedDate.value) {
uni.showToast({ title: '请选择预约日期', icon: 'none' })
return false
}
if (!selectedTime.value) {
uni.showToast({ title: '请选择预约时间', icon: 'none' })
return false
}
if (!formData.value.name.trim()) {
uni.showToast({ title: '请输入姓名', icon: 'none' })
return false
}
if (!isPhone(formData.value.phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return false
}
if (!formData.value.grade) {
uni.showToast({ title: '请选择年级', icon: 'none' })
return false
}
if (showScoreField.value && !formData.value.score.trim()) {
uni.showToast({ title: '请输入成绩', icon: 'none' })
return false
}
return true
}
/**
* 提交预约
*/
async function handleSubmit() {
if (!isFormComplete.value) return
// 检查登录
if (!checkLogin()) return
// 验证表单
if (!validateForm()) return
submitting.value = true
try {
uni.showLoading({ title: '创建订单中...' })
// 发起支付
const result = await processPayment({
productType: 2, // 规划预约类型
productId: plannerId.value,
userInfo: {
name: formData.value.name,
phone: formData.value.phone,
grade: formData.value.grade,
score: formData.value.score || '',
bookDate: selectedDate.value,
bookTime: selectedTime.value,
plannerId: plannerId.value,
plannerName: plannerInfo.value.name
}
})
uni.hideLoading()
if (result.success) {
// 支付成功,显示预约成功弹窗
bookingInfo.value = {
date: selectedDate.value,
time: selectedTime.value,
plannerName: plannerInfo.value.name
}
showSuccessPopup.value = true
} else if (result.error) {
uni.showToast({
title: result.error,
icon: 'none'
})
}
} catch (error) {
uni.hideLoading()
console.error('预约失败:', error)
uni.showToast({
title: '预约失败,请重试',
icon: 'none'
})
} finally {
submitting.value = false
}
}
/**
* 关闭成功弹窗
*/
function closeSuccessPopup() {
showSuccessPopup.value = false
// 返回上一页或跳转到订单列表
uni.navigateBack()
}
/**
* 查看订单
*/
function goToOrderList() {
showSuccessPopup.value = false
uni.navigateTo({
url: '/pages/order/list/index'
})
}
/**
* 格式化价格
*/
function formatPrice(price) {
if (price === undefined || price === null) return '0'
const num = Number(price)
if (isNaN(num)) return '0'
if (Number.isInteger(num)) return num.toString()
return num.toFixed(2)
}
// 监听日期变化,加载对应时间段
watch(selectedDate, () => {
loadAvailableTime()
})
/**
* 页面加载
*/
onLoad(async (options) => {
plannerId.value = Number(options.plannerId) || 0
plannerName.value = decodeURIComponent(options.plannerName || '')
// 恢复用户登录状态
userStore.restoreFromStorage()
// 如果已登录,预填手机号
if (userStore.isLoggedIn && userStore.phone) {
formData.value.phone = userStore.phone
}
// 生成日期列表
generateDateList()
// 加载规划师详情
await loadPlannerDetail()
pageLoading.value = false
})
</script>
<template>
<view class="planner-book-page">
<!-- 页面内容 -->
<view class="page-content">
<!-- 规划师信息卡片 -->
<view class="planner-card">
<image
:src="plannerInfo.avatar || '/static/ic_empty.png'"
mode="aspectFill"
class="planner-avatar"
/>
<view class="planner-info">
<view class="planner-name">{{ plannerInfo.name }}</view>
<view class="planner-intro">{{ plannerInfo.intro || '专业学业规划师' }}</view>
</view>
<view class="planner-price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ formatPrice(plannerInfo.price) }}</text>
<text class="price-unit">/</text>
</view>
</view>
<!-- 日期选择 -->
<view class="section">
<view class="section-title">选择日期</view>
<scroll-view scroll-x class="date-scroll">
<view class="date-list">
<view
v-for="(item, index) in dateList"
:key="item.date"
class="date-item"
:class="{ 'active': currentDateIndex === index }"
@click="selectDate(index)"
>
<view class="date-label">{{ item.label }}</view>
<view class="date-day">{{ item.day }}</view>
</view>
</view>
</scroll-view>
</view>
<!-- 时间选择 -->
<view class="section">
<view class="section-title">选择时间</view>
<view v-if="timeLoading" class="time-loading">
<text>加载中...</text>
</view>
<view v-else-if="timeList.length === 0" class="time-empty">
<text>暂无可预约时间</text>
</view>
<view v-else class="time-grid">
<view
v-for="item in timeList"
:key="item.time"
class="time-item"
:class="{
'active': selectedTime === item.time,
'disabled': !item.available
}"
@click="selectTime(item)"
>
{{ item.time }}
</view>
</view>
</view>
<!-- 表单区域 -->
<view class="section form-section">
<view class="section-title">填写信息</view>
<!-- 姓名 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>姓名</text>
</view>
<input
class="form-input"
type="text"
placeholder="请输入姓名"
v-model="formData.name"
maxlength="20"
/>
</view>
<!-- 手机号 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>手机号</text>
</view>
<input
class="form-input"
type="number"
placeholder="请输入手机号"
v-model="formData.phone"
maxlength="11"
/>
</view>
<!-- 年级 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>年级</text>
</view>
<picker
mode="selector"
:range="gradeOptions"
@change="onGradeChange"
>
<view class="form-picker">
<text :class="{ 'placeholder': !formData.grade }">
{{ formData.grade || '请选择年级' }}
</text>
<view class="picker-arrow"></view>
</view>
</picker>
</view>
<!-- 成绩(根据年级动态显示) -->
<view v-if="showScoreField" class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>成绩</text>
</view>
<input
class="form-input"
type="text"
placeholder="请输入近期考试成绩/排名"
v-model="formData.score"
maxlength="50"
/>
</view>
</view>
<!-- 底部占位 -->
<view class="bottom-placeholder"></view>
</view>
<!-- 底部按钮 -->
<view class="bottom-bar">
<view class="price-info">
<text class="price-label">合计:</text>
<text class="price-symbol">¥</text>
<text class="price-amount">{{ formatPrice(plannerInfo.price) }}</text>
</view>
<view
class="submit-btn"
:class="{ 'disabled': !isFormComplete || submitting }"
@click="handleSubmit"
>
{{ submitting ? '提交中...' : '立即预约' }}
</view>
</view>
<!-- 预约成功弹窗 -->
<view v-if="showSuccessPopup" class="popup-mask" @click.stop>
<view class="success-popup">
<!-- 成功图标 -->
<view class="success-icon">
<view class="icon-circle">
<text class="icon-check">✓</text>
</view>
</view>
<!-- 成功标题 -->
<view class="success-title">预约成功</view>
<!-- 预约信息 -->
<view class="booking-info">
<view class="info-item">
<text class="info-label">规划师:</text>
<text class="info-value">{{ bookingInfo.plannerName }}</text>
</view>
<view class="info-item">
<text class="info-label">预约日期:</text>
<text class="info-value">{{ bookingInfo.date }}</text>
</view>
<view class="info-item">
<text class="info-label">预约时间:</text>
<text class="info-value">{{ bookingInfo.time }}</text>
</view>
</view>
<!-- 提示文字 -->
<view class="success-tip">
规划师将在预约时间与您联系,请保持手机畅通
</view>
<!-- 按钮 -->
<view class="popup-buttons">
<view class="popup-btn secondary" @click="closeSuccessPopup">返回</view>
<view class="popup-btn primary" @click="goToOrderList">查看订单</view>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.planner-book-page {
min-height: 100vh;
background-color: $bg-color;
}
.page-content {
padding: $spacing-lg;
padding-bottom: 180rpx;
}
// 规划师信息卡片
.planner-card {
display: flex;
align-items: center;
background-color: $bg-white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
margin-bottom: $spacing-lg;
box-shadow: $shadow-sm;
.planner-avatar {
width: 120rpx;
height: 120rpx;
border-radius: $border-radius-md;
flex-shrink: 0;
margin-right: $spacing-md;
}
.planner-info {
flex: 1;
min-width: 0;
.planner-name {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-color;
margin-bottom: $spacing-xs;
}
.planner-intro {
font-size: $font-size-sm;
color: $text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.planner-price {
display: flex;
align-items: baseline;
flex-shrink: 0;
.price-symbol {
font-size: $font-size-sm;
color: $error-color;
}
.price-value {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $error-color;
}
.price-unit {
font-size: $font-size-xs;
color: $text-placeholder;
margin-left: 4rpx;
}
}
}
// 区块样式
.section {
background-color: $bg-white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
margin-bottom: $spacing-lg;
.section-title {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-color;
margin-bottom: $spacing-md;
}
}
// 日期选择
.date-scroll {
white-space: nowrap;
margin: 0 -#{$spacing-lg};
padding: 0 $spacing-lg;
}
.date-list {
display: inline-flex;
gap: $spacing-md;
}
.date-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
background-color: $bg-gray;
border-radius: $border-radius-md;
transition: all $transition-fast;
.date-label {
font-size: $font-size-sm;
color: $text-secondary;
margin-bottom: 4rpx;
}
.date-day {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $text-color;
}
&.active {
background-color: $primary-color;
.date-label,
.date-day {
color: $text-white;
}
}
&:active {
opacity: 0.8;
}
}
// 时间选择
.time-loading,
.time-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200rpx;
text {
font-size: $font-size-md;
color: $text-placeholder;
}
}
.time-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $spacing-md;
}
.time-item {
display: flex;
align-items: center;
justify-content: center;
height: 72rpx;
background-color: $bg-gray;
border-radius: $border-radius-sm;
font-size: $font-size-md;
color: $text-color;
transition: all $transition-fast;
&.active {
background-color: $primary-color;
color: $text-white;
}
&.disabled {
background-color: $bg-gray;
color: $text-disabled;
pointer-events: none;
}
&:active {
opacity: 0.8;
}
}
// 表单区域
.form-section {
padding: 0 $spacing-lg;
}
.form-item {
display: flex;
align-items: center;
padding: $spacing-lg 0;
border-bottom: 1rpx solid $border-light;
&:last-child {
border-bottom: none;
}
}
.form-label {
width: 140rpx;
flex-shrink: 0;
font-size: $font-size-md;
color: $text-color;
.required {
color: $error-color;
margin-right: 4rpx;
}
}
.form-input {
flex: 1;
height: 48rpx;
font-size: $font-size-md;
color: $text-color;
&::placeholder {
color: $text-placeholder;
}
}
.form-picker {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
height: 48rpx;
font-size: $font-size-md;
color: $text-color;
.placeholder {
color: $text-placeholder;
}
.picker-arrow {
width: 16rpx;
height: 16rpx;
border-right: 3rpx solid $text-placeholder;
border-bottom: 3rpx solid $text-placeholder;
transform: rotate(-45deg);
margin-left: $spacing-sm;
}
}
// 底部占位
.bottom-placeholder {
height: 40rpx;
}
// 底部按钮栏
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
background-color: $bg-white;
padding: $spacing-md $spacing-lg;
padding-bottom: calc(#{$spacing-md} + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
.price-info {
display: flex;
align-items: baseline;
.price-label {
font-size: $font-size-md;
color: $text-secondary;
}
.price-symbol {
font-size: $font-size-md;
color: $error-color;
font-weight: $font-weight-medium;
}
.price-amount {
font-size: $font-size-xxl;
color: $error-color;
font-weight: $font-weight-bold;
}
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 240rpx;
height: 88rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF5252 100%);
border-radius: 44rpx;
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-white;
&:active {
opacity: 0.8;
}
&.disabled {
background: #CCCCCC;
pointer-events: none;
}
}
}
// 弹窗遮罩
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $bg-mask;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
// 成功弹窗
.success-popup {
width: 600rpx;
background-color: $bg-white;
border-radius: $border-radius-xl;
padding: $spacing-xl;
text-align: center;
}
.success-icon {
margin-bottom: $spacing-lg;
.icon-circle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, #52C41A 0%, #73D13D 100%);
border-radius: 50%;
.icon-check {
font-size: 60rpx;
color: $text-white;
font-weight: $font-weight-bold;
}
}
}
.success-title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-color;
margin-bottom: $spacing-lg;
}
.booking-info {
background-color: $bg-gray;
border-radius: $border-radius-md;
padding: $spacing-md $spacing-lg;
margin-bottom: $spacing-lg;
text-align: left;
.info-item {
display: flex;
padding: $spacing-xs 0;
.info-label {
font-size: $font-size-md;
color: $text-secondary;
width: 160rpx;
flex-shrink: 0;
}
.info-value {
font-size: $font-size-md;
color: $text-color;
flex: 1;
}
}
}
.success-tip {
font-size: $font-size-sm;
color: $text-placeholder;
margin-bottom: $spacing-xl;
line-height: 1.5;
}
.popup-buttons {
display: flex;
gap: $spacing-md;
.popup-btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 40rpx;
font-size: $font-size-md;
font-weight: $font-weight-medium;
&.secondary {
background-color: $bg-gray;
color: $text-secondary;
}
&.primary {
background: linear-gradient(135deg, #FF6B6B 0%, #FF5252 100%);
color: $text-white;
}
&:active {
opacity: 0.8;
}
}
}
</style>