mi-assessment/uniapp/pages/planner/book/index.vue
2026-02-23 20:31:26 +08:00

764 lines
20 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 } 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 } 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: '', introduction: '', price: 0
})
// 规划时间(两步选择:先日期后时间)
const selectedDate = ref('')
const selectedTime = ref('')
const dateTimeDisplay = ref('')
const showTimePicker = ref(false)
// 可选时间列表
const timeOptions = [
'09:00', '10:00', '11:00', '12:00',
'13:00', '14:00', '15:00', '16:00',
'17:00', '18:00', '19:00', '20:00'
]
// 表单数据
const formData = ref({
name: '', phone: '', grade: '',
majorName: '', familyAtmosphere: '', expectation: ''
})
// 各科成绩
const scores = ref({
chinese: '', math: '', english: '',
physics: '', chemistry: '', biology: '',
geography: '', politics: ''
})
// 年级选项
const gradeOptions = ['小学', '初中', '高中', '大专', '本科', '研究生及以上']
const gradeIndex = ref(-1)
// 根据年级判断分类
const gradeCategory = computed(() => {
const g = formData.value.grade
if (g === '小学') return 'primary'
if (g === '初中' || g === '高中') return 'secondary'
if (g === '大专' || g === '本科' || g === '研究生及以上') return 'college'
return ''
})
// 是否显示各科成绩
const showScores = computed(() => {
return gradeCategory.value === 'primary' || gradeCategory.value === 'secondary'
})
// 是否显示专业名称
const showMajor = computed(() => {
return gradeCategory.value === 'college'
})
// 当前年级需要显示的科目列表
const subjectList = computed(() => {
if (gradeCategory.value === 'primary') {
return [
{ key: 'chinese', label: '语文', required: true },
{ key: 'math', label: '数学', required: true },
{ key: 'english', label: '英语', required: true }
]
}
if (gradeCategory.value === 'secondary') {
return [
{ key: 'chinese', label: '语文', required: true },
{ key: 'math', label: '数学', required: true },
{ key: 'english', label: '英语', required: true },
{ key: 'physics', label: '物理', required: false },
{ key: 'chemistry', label: '化学', required: false },
{ key: 'biology', label: '生物', required: false },
{ key: 'geography', label: '地理', required: false },
{ key: 'politics', label: '政治', required: false }
]
}
return []
})
// 页面状态
const pageLoading = ref(true)
const submitting = ref(false)
const showSuccessPopup = ref(false)
/**
* 表单是否填写完整
*/
const isFormComplete = computed(() => {
if (!selectedDate.value || !selectedTime.value) return false
if (!formData.value.name.trim()) return false
if (!formData.value.phone.trim()) return false
if (!formData.value.grade) return false
if (showScores.value) {
if (!scores.value.chinese.trim() || !scores.value.math.trim() || !scores.value.english.trim()) return false
}
if (showMajor.value && !formData.value.majorName.trim()) return false
if (!formData.value.familyAtmosphere.trim()) return false
if (!formData.value.expectation.trim()) return false
return true
})
/**
* 加载规划师详情
*/
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 || '',
introduction: res.data.introduction || '',
price: res.data.price || 0
}
} else {
plannerInfo.value.name = plannerName.value || '规划师'
}
} catch (error) {
console.error('加载规划师详情失败:', error)
plannerInfo.value.name = plannerName.value || '规划师'
}
}
/**
* 获取日期选择器起始日期
*/
function getStartDate() {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/**
* 获取日期选择器结束日期
*/
function getEndDate() {
const now = new Date()
return `${now.getFullYear() + 1}-12-31`
}
/**
* 选择日期后弹出时间选择
*/
function onDateChange(e) {
selectedDate.value = e.detail.value
showTimePicker.value = true
}
/**
* 选择时间
*/
function onTimePick(time) {
selectedTime.value = time
showTimePicker.value = false
const p = selectedDate.value.split('-')
dateTimeDisplay.value = `${p[0]}${parseInt(p[1])}${parseInt(p[2])}${time}`
}
/**
* 关闭时间选择弹窗
*/
function closeTimePicker() {
showTimePicker.value = false
if (!selectedTime.value) {
selectedDate.value = ''
}
}
/**
* 年级选择
*/
function onGradeChange(e) {
gradeIndex.value = e.detail.value
formData.value.grade = gradeOptions[e.detail.value]
scores.value = {
chinese: '', math: '', english: '',
physics: '', chemistry: '', biology: '',
geography: '', politics: ''
}
formData.value.majorName = ''
}
/**
* 验证表单
*/
function validateForm() {
if (!selectedDate.value || !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 (showScores.value && (!scores.value.chinese.trim() || !scores.value.math.trim() || !scores.value.english.trim())) {
uni.showToast({ title: '请填写必填科目成绩', icon: 'none' }); return false
}
if (showMajor.value && !formData.value.majorName.trim()) {
uni.showToast({ title: '请输入专业名称', icon: 'none' }); return false
}
if (!formData.value.familyAtmosphere.trim()) {
uni.showToast({ title: '请输入家庭氛围', icon: 'none' }); return false
}
if (!formData.value.expectation.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 gradeMap = { '小学': 1, '初中': 2, '高中': 3, '大专': 4, '本科': 5, '研究生及以上': 6 }
// 构建成绩数据
const scoreFields = {}
if (showScores.value) {
subjectList.value.forEach(s => {
if (scores.value[s.key]) {
const fieldMap = {
chinese: 'scoreChinese', math: 'scoreMath', english: 'scoreEnglish',
physics: 'scorePhysics', chemistry: 'scoreChemistry', biology: 'scoreBiology',
geography: 'scoreGeography', politics: 'scorePolitics'
}
scoreFields[fieldMap[s.key]] = parseInt(scores.value[s.key]) || null
}
})
}
const result = await processPayment({
orderType: 2,
productId: plannerId.value,
plannerInfo: {
name: formData.value.name,
phone: formData.value.phone,
bookDateTime: `${selectedDate.value} ${selectedTime.value}`,
grade: gradeMap[formData.value.grade] || 0,
majorName: showMajor.value ? formData.value.majorName : null,
...scoreFields,
familyAtmosphere: formData.value.familyAtmosphere,
expectation: formData.value.expectation
}
})
uni.hideLoading()
if (result.success) {
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()
}
/**
* 页面加载
*/
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
}
await loadPlannerDetail()
pageLoading.value = false
})
</script>
<template>
<view class="book-page">
<view class="page-content">
<!-- 主卡片 -->
<view class="main-card">
<!-- 规划时间 -->
<view class="section-block">
<view class="section-title">我的规划时间</view>
<picker
mode="date"
:start="getStartDate()"
:end="getEndDate()"
@change="onDateChange"
>
<view class="datetime-picker">
<text class="picker-label">规划时间</text>
<text v-if="!dateTimeDisplay" class="picker-placeholder">请选择 〉</text>
<text v-else class="picker-value">{{ dateTimeDisplay }} 〉</text>
</view>
</picker>
</view>
<!-- 个人信息 -->
<view class="section-block">
<view class="section-title">您的个人信息</view>
<!-- 姓名 -->
<view class="field-group">
<view class="field-label"><text class="required">*</text><text>姓名</text></view>
<input class="field-input" type="text" placeholder="请输入" v-model="formData.name" maxlength="20" />
</view>
<!-- 联系方式 -->
<view class="field-group">
<view class="field-label"><text class="required">*</text><text>联系方式</text></view>
<input class="field-input" type="number" placeholder="请输入" v-model="formData.phone" maxlength="11" />
</view>
<!-- 所在年级 -->
<view class="field-group">
<view class="field-label"><text class="required">*</text><text>所在年级</text></view>
<picker mode="selector" :range="gradeOptions" @change="onGradeChange">
<view class="field-select">
<text :class="{ 'placeholder-text': !formData.grade }">{{ formData.grade || '请选择' }}</text>
<text class="select-arrow"></text>
</view>
</picker>
</view>
<!-- 各科最近成绩(小学/初中/高中) -->
<view v-if="showScores" class="scores-section">
<view class="section-subtitle">各科最近成绩</view>
<view v-for="subject in subjectList" :key="subject.key" class="score-item">
<view class="score-label">
<text v-if="subject.required" class="required">*</text>
<text>{{ subject.label }}</text>
</view>
<input class="score-input" type="text" placeholder="请输入" v-model="scores[subject.key]" maxlength="20" />
</view>
</view>
<!-- 专业名称(大专/本科/研究生及以上) -->
<view v-if="showMajor" class="field-group">
<view class="field-label"><text class="required">*</text><text>专业名称</text></view>
<input class="field-input" type="text" placeholder="请输入" v-model="formData.majorName" maxlength="50" />
</view>
<!-- 家庭氛围 -->
<view class="field-group">
<view class="field-label"><text class="required">*</text><text>家庭氛围</text></view>
<input class="field-input" type="text" placeholder="请输入" v-model="formData.familyAtmosphere" maxlength="200" />
</view>
<!-- 期望 -->
<view class="field-group">
<view class="field-label"><text class="required">*</text><text>期望</text></view>
<input class="field-input" type="text" placeholder="请输入" v-model="formData.expectation" maxlength="200" />
</view>
</view>
</view>
<view class="bottom-placeholder"></view>
</view>
<!-- 底部按钮 -->
<view class="bottom-bar">
<view class="submit-btn" :class="{ 'disabled': !isFormComplete || submitting }" @click="handleSubmit">
{{ submitting ? '提交中...' : '确定并支付预付费' }}
</view>
</view>
<!-- 时间选择弹窗 -->
<view v-if="showTimePicker" class="popup-mask" @click="closeTimePicker">
<view class="time-popup" @click.stop>
<view class="time-popup-header">
<text class="time-popup-title">选择时间</text>
<text class="time-popup-close" @click="closeTimePicker">✕</text>
</view>
<view class="time-grid">
<view
v-for="time in timeOptions"
:key="time"
class="time-option"
:class="{ 'active': selectedTime === time }"
@click="onTimePick(time)"
>{{ time }}</view>
</view>
</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="success-subtitle">请等待规划师联系您</view>
<view class="confirm-btn" @click="closeSuccessPopup">确定</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.book-page {
min-height: 100vh;
background: linear-gradient(180deg, #FFF5E6 0%, $bg-color 30%);
}
.page-content {
padding: $spacing-lg;
padding-bottom: 180rpx;
}
// 主卡片
.main-card {
background-color: $bg-white;
border-radius: $border-radius-lg;
padding: $spacing-xl $spacing-lg;
}
// 区块
.section-block {
margin-bottom: $spacing-xl;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-color;
margin-bottom: $spacing-md;
}
.section-subtitle {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $text-color;
margin-top: $spacing-lg;
margin-bottom: $spacing-md;
}
// 日期时间选择器
.datetime-picker {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
background-color: $bg-gray;
border-radius: $border-radius-sm;
padding: 0 $spacing-lg;
.picker-label {
font-size: $font-size-md;
color: $text-color;
}
.picker-placeholder {
font-size: $font-size-md;
color: $text-placeholder;
}
.picker-value {
font-size: $font-size-md;
color: $text-color;
}
}
// 表单字段
.field-group {
margin-bottom: $spacing-lg;
}
.field-label {
font-size: $font-size-md;
color: $text-color;
margin-bottom: $spacing-sm;
.required {
color: $error-color;
margin-right: 4rpx;
}
}
.field-input {
height: 80rpx;
background-color: $bg-gray;
border-radius: $border-radius-sm;
padding: 0 $spacing-lg;
font-size: $font-size-md;
color: $text-color;
}
.field-select {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
background-color: $bg-gray;
border-radius: $border-radius-sm;
padding: 0 $spacing-lg;
font-size: $font-size-md;
color: $text-color;
.placeholder-text {
color: $text-placeholder;
}
.select-arrow {
font-size: $font-size-lg;
color: $text-secondary;
}
}
// 成绩区域
.scores-section {
margin-top: $spacing-sm;
}
.score-item {
display: flex;
align-items: center;
margin-bottom: $spacing-md;
.score-label {
width: 120rpx;
flex-shrink: 0;
font-size: $font-size-md;
color: $text-color;
.required {
color: $error-color;
margin-right: 4rpx;
}
}
.score-input {
flex: 1;
height: 72rpx;
background-color: $bg-gray;
border-radius: $border-radius-sm;
padding: 0 $spacing-lg;
font-size: $font-size-md;
color: $text-color;
}
}
// 底部占位
.bottom-placeholder {
height: 40rpx;
}
// 底部按钮栏
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: $spacing-lg;
padding-bottom: calc(#{$spacing-lg} + env(safe-area-inset-bottom));
background-color: transparent;
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
background: linear-gradient(135deg, #FFB347 0%, #FF8C00 100%);
border-radius: 48rpx;
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-white;
&:active {
opacity: 0.8;
}
&.disabled {
background: $text-disabled;
pointer-events: none;
}
}
// 弹窗遮罩
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
// 时间选择弹窗
.time-popup {
width: 600rpx;
background-color: $bg-white;
border-radius: $border-radius-xl;
padding: $spacing-xl;
.time-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $spacing-lg;
.time-popup-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-color;
}
.time-popup-close {
font-size: $font-size-xl;
color: $text-placeholder;
padding: $spacing-xs;
}
}
.time-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $spacing-md;
}
.time-option {
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;
&.active {
background: linear-gradient(135deg, #FFB347 0%, #FF8C00 100%);
color: $text-white;
}
&:active {
opacity: 0.8;
}
}
}
// 成功弹窗
.success-popup {
width: 560rpx;
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-sm;
}
.success-subtitle {
font-size: $font-size-md;
color: $text-secondary;
margin-bottom: $spacing-xl;
}
.confirm-btn {
display: flex;
align-items: center;
justify-content: center;
height: 80rpx;
background-color: #999999;
border-radius: 40rpx;
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $text-white;
margin: 0 $spacing-xl;
&:active {
opacity: 0.8;
}
}
</style>