- 新增地址类型定义,增强前端地址数据结构 - 新增地址编辑页面,支持地址智能识别和定位选点功能 - 地址编辑支持省市区选择及默认地址设置 - 新增地址列表页面,支持地址展示、删除、编辑和选择功能 - 实现售后申请页面,支持选择售后类型和退款原因 - 售后申请支持商品选择、退款金额计算和凭证上传 - 新增售后详情页面,支持售后状态展示及申请取消 - 优化页面加载和用户交互体验,增加错误提示和权限处理
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
||
import { View, Text, ScrollView, Input } from '@tarojs/components'
|
||
import Taro from '@tarojs/taro'
|
||
import { useUser } from '@/hooks/useUser'
|
||
import { createShopBooking } from '@/api/shop/shopBooking'
|
||
import { getShopStore } from '@/api/shop/shopStore'
|
||
import type { ShopStore } from '@/api/shop/shopStore/model'
|
||
import BottomButton from '@/components/common/BottomButton'
|
||
import './index.scss'
|
||
import dayjs from 'dayjs'
|
||
|
||
definePageConfig({
|
||
navigationBarTitleText: '穿线预约',
|
||
})
|
||
|
||
const BookingPage: React.FC = () => {
|
||
const { isLoggedIn } = useUser()
|
||
const { storeId: initStoreId } = Taro.getCurrentInstance().router?.params || {}
|
||
|
||
// 门店信息
|
||
const [storeInfo, setStoreInfo] = useState<ShopStore | null>(null)
|
||
// 表单数据
|
||
const [date, setDate] = useState('')
|
||
const [time, setTime] = useState('')
|
||
const [serviceType, setServiceType] = useState('')
|
||
const [contactName, setContactName] = useState('')
|
||
const [contactPhone, setContactPhone] = useState('')
|
||
const [remark, setRemark] = useState('')
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
// 日期范围(今天起30天)
|
||
const dateRange = (() => {
|
||
const dates: string[] = []
|
||
const today = dayjs()
|
||
for (let i = 0; i < 30; i++) {
|
||
dates.push(today.add(i, 'day').format('YYYY-MM-DD'))
|
||
}
|
||
return dates
|
||
})()
|
||
|
||
// 全部可选时间段
|
||
const allTimeSlots = [
|
||
'09:00-10:00', '10:00-11:00', '11:00-12:00',
|
||
'14:00-15:00', '15:00-16:00', '16:00-17:00', '17:00-18:00',
|
||
]
|
||
|
||
// 根据选中日期过滤已过去的时间段
|
||
const timeSlots = (() => {
|
||
if (!date) return allTimeSlots
|
||
const today = dayjs().format('YYYY-MM-DD')
|
||
if (date !== today) return allTimeSlots
|
||
// 今天:过滤掉已结束的时间段
|
||
const now = dayjs()
|
||
return allTimeSlots.filter(slot => {
|
||
const endTime = slot.split('-')[1] // 取结束时间如 "10:00"
|
||
const [h, m] = endTime.split(':').map(Number)
|
||
const slotEnd = dayjs().hour(h).minute(m).second(0)
|
||
return slotEnd.isAfter(now)
|
||
})
|
||
})()
|
||
|
||
// 服务类型
|
||
const serviceTypes = [
|
||
{ label: '穿线服务', value: '穿线服务', price: 30 },
|
||
{ label: '穿线+手胶', value: '穿线+手胶', price: 50 },
|
||
{ label: '穿线+毛巾胶', value: '穿线+毛巾胶', price: 60 },
|
||
{ label: '其他', value: '其他', price: 0 },
|
||
]
|
||
|
||
const selectedPrice = serviceTypes.find(s => s.value === serviceType)?.price || 0
|
||
|
||
// 切换日期时,清除已选时间(如果当前时间在新日期不可用)
|
||
const handleDateChange = (dateStr: string) => {
|
||
setDate(dateStr)
|
||
// 检查当前选中的时间在新的 timeSlots 中是否存在
|
||
const today = dayjs().format('YYYY-MM-DD')
|
||
if (dateStr === today) {
|
||
const now = dayjs()
|
||
const availableSlots = allTimeSlots.filter(slot => {
|
||
const endTime = slot.split('-')[1]
|
||
const [h, m] = endTime.split(':').map(Number)
|
||
const slotEnd = dayjs().hour(h).minute(m).second(0)
|
||
return slotEnd.isAfter(now)
|
||
})
|
||
if (time && !availableSlots.includes(time)) {
|
||
setTime('')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化
|
||
useEffect(() => {
|
||
if (!isLoggedIn) {
|
||
Taro.navigateTo({ url: '/passport/login' })
|
||
return
|
||
}
|
||
if (initStoreId) {
|
||
fetchStoreInfo(Number(initStoreId))
|
||
}
|
||
// 挂载回调,供门店列表页选中后回传
|
||
const pages = Taro.getCurrentPages()
|
||
const curPage = pages[pages.length - 1] as any
|
||
curPage.setSelectedStore = (store: ShopStore) => {
|
||
setStoreInfo(store)
|
||
}
|
||
}, [isLoggedIn])
|
||
|
||
const fetchStoreInfo = async (id: number) => {
|
||
try {
|
||
const data = await getShopStore(id)
|
||
setStoreInfo(data)
|
||
} catch (e) {
|
||
console.error('获取门店信息失败:', e)
|
||
}
|
||
}
|
||
|
||
// 跳转到门店选择列表
|
||
const handleSelectStore = () => {
|
||
Taro.navigateTo({ url: '/pages/store/list/index?selectMode=1' })
|
||
}
|
||
|
||
const formatDateDisplay = (dateStr: string) => {
|
||
const d = dayjs(dateStr)
|
||
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
return `${d.month() + 1}月${d.date()}日 ${weekDays[d.day()]}`
|
||
}
|
||
|
||
const validateForm = () => {
|
||
if (!storeInfo) {
|
||
Taro.showToast({ title: '请选择预约门店', icon: 'none' })
|
||
return false
|
||
}
|
||
if (!date) {
|
||
Taro.showToast({ title: '请选择预约日期', icon: 'none' })
|
||
return false
|
||
}
|
||
if (!time) {
|
||
Taro.showToast({ title: '请选择预约时间段', icon: 'none' })
|
||
return false
|
||
}
|
||
if (!serviceType) {
|
||
Taro.showToast({ title: '请选择服务类型', icon: 'none' })
|
||
return false
|
||
}
|
||
if (!contactName.trim()) {
|
||
Taro.showToast({ title: '请输入联系人姓名', icon: 'none' })
|
||
return false
|
||
}
|
||
if (!contactPhone.trim()) {
|
||
Taro.showToast({ title: '请输入手机号', icon: 'none' })
|
||
return false
|
||
}
|
||
if (!/^1[3-9]\d{9}$/.test(contactPhone)) {
|
||
Taro.showToast({ title: '手机号格式不正确', icon: 'none' })
|
||
return false
|
||
}
|
||
// 校验预约时间不能是过去
|
||
const endTime = time.split('-')[1]
|
||
const [h, m] = endTime.split(':').map(Number)
|
||
const bookingDateTime = dayjs(date).hour(h).minute(m).second(0)
|
||
if (bookingDateTime.isBefore(dayjs())) {
|
||
Taro.showToast({ title: '预约时间不能是过去的时间', icon: 'none' })
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
if (!validateForm()) return
|
||
const confirmContent = `门店:${storeInfo!.name}\n日期:${formatDateDisplay(date)}\n时段:${time}\n服务:${serviceType}${selectedPrice > 0 ? `(¥${selectedPrice})` : ''}\n联系人:${contactName}\n电话:${contactPhone}`
|
||
Taro.showModal({
|
||
title: '确认预约',
|
||
content: confirmContent,
|
||
confirmText: '确认预约',
|
||
confirmColor: '#0e932e',
|
||
success: (res) => {
|
||
if (res.confirm) submitBooking()
|
||
},
|
||
})
|
||
}
|
||
|
||
const submitBooking = async () => {
|
||
try {
|
||
setSubmitting(true)
|
||
await createShopBooking({
|
||
storeId: storeInfo!.id || 0,
|
||
storeName: storeInfo!.name || '',
|
||
serviceId: 0,
|
||
serviceName: serviceType,
|
||
bookingDate: date,
|
||
bookingTime: time,
|
||
status: 'pending',
|
||
price: selectedPrice,
|
||
remark,
|
||
contactName,
|
||
contactPhone,
|
||
address: storeInfo!.address || '',
|
||
})
|
||
Taro.showToast({ title: '预约成功', icon: 'success' })
|
||
setTimeout(() => Taro.navigateBack(), 1500)
|
||
} catch (e: any) {
|
||
Taro.showToast({ title: e.message || '预约失败', icon: 'none' })
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<View className='booking-page'>
|
||
<ScrollView scrollY className='booking-scroll'>
|
||
|
||
{/* 选择门店 */}
|
||
<View className='bg-white mx-3 mt-3 rounded-xl overflow-hidden'>
|
||
<View
|
||
className='flex items-center px-4 py-3'
|
||
onClick={handleSelectStore}
|
||
>
|
||
<Text className='text-sm font-medium text-gray-700 mr-3'>预约门店</Text>
|
||
<View className='flex-1 flex items-center justify-between'>
|
||
{storeInfo ? (
|
||
<View className='flex-1'>
|
||
<Text className='text-sm text-gray-800 font-medium block'>{storeInfo.name}</Text>
|
||
{storeInfo.address && (
|
||
<Text className='text-xs text-gray-400 mt-0.5 block'>{storeInfo.address}</Text>
|
||
)}
|
||
{storeInfo.businessHours && (
|
||
<Text className='text-xs text-gray-400 mt-0.5 block'>🕐 {storeInfo.businessHours}</Text>
|
||
)}
|
||
</View>
|
||
) : (
|
||
<Text className='text-sm text-gray-400 flex-1'>请选择门店</Text>
|
||
)}
|
||
<Text className='text-gray-300 ml-2 text-base'>›</Text>
|
||
</View>
|
||
</View>
|
||
{/* 门店操作快捷按钮(已选门店时显示) */}
|
||
{storeInfo && (
|
||
<View className='flex border-t border-gray-50'>
|
||
{storeInfo.phone && (
|
||
<View
|
||
className='flex-1 py-2 flex items-center justify-center gap-1'
|
||
onClick={() => Taro.makePhoneCall({ phoneNumber: storeInfo.phone! })}
|
||
>
|
||
<Text className='text-xs text-blue-500'>📞 拨打电话</Text>
|
||
</View>
|
||
)}
|
||
{storeInfo.lngAndLat && (
|
||
<View
|
||
className='flex-1 py-2 flex items-center justify-center gap-1 border-l border-gray-50'
|
||
onClick={() => {
|
||
const parts = storeInfo.lngAndLat!.split(',')
|
||
if (parts.length === 2) {
|
||
Taro.openLocation({
|
||
longitude: parseFloat(parts[0]),
|
||
latitude: parseFloat(parts[1]),
|
||
name: storeInfo.name || '',
|
||
address: storeInfo.address || '',
|
||
})
|
||
}
|
||
}}
|
||
>
|
||
<Text className='text-xs text-green-500'>🗺 查看地图</Text>
|
||
</View>
|
||
)}
|
||
<View
|
||
className='flex-1 py-2 flex items-center justify-center gap-1 border-l border-gray-50'
|
||
onClick={handleSelectStore}
|
||
>
|
||
<Text className='text-xs text-orange-500'>🔄 重新选择</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* 选择日期 */}
|
||
<View className='bg-white mx-3 mt-3 p-3 rounded-xl'>
|
||
<Text className='text-sm font-medium text-gray-700 mb-3 block'>选择日期</Text>
|
||
<ScrollView scrollX className='whitespace-nowrap'>
|
||
<View className='flex gap-2'>
|
||
{dateRange.map(dateStr => (
|
||
<View
|
||
key={dateStr}
|
||
className={`inline-flex flex-col items-center px-3 py-2 rounded-lg min-w-20 ${
|
||
date === dateStr ? 'bg-green-500' : 'bg-gray-50'
|
||
}`}
|
||
onClick={() => handleDateChange(dateStr)}
|
||
>
|
||
<Text className={`text-xs ${date === dateStr ? 'text-white' : 'text-gray-600'}`}>
|
||
{formatDateDisplay(dateStr)}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
|
||
{/* 选择时间 */}
|
||
<View className='bg-white mx-3 mt-3 p-3 rounded-xl'>
|
||
<Text className='text-sm font-medium text-gray-700 mb-3 block'>选择时间</Text>
|
||
<View className='flex flex-wrap gap-2'>
|
||
{timeSlots.map(slot => (
|
||
<View
|
||
key={slot}
|
||
className={`px-4 py-2 rounded-lg ${
|
||
time === slot ? 'bg-green-500' : 'bg-gray-50'
|
||
}`}
|
||
onClick={() => setTime(slot)}
|
||
>
|
||
<Text className={`text-sm ${time === slot ? 'text-white' : 'text-gray-600'}`}>
|
||
{slot}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
|
||
{/* 服务类型 */}
|
||
<View className='bg-white mx-3 mt-3 p-3 rounded-xl'>
|
||
<Text className='text-sm font-medium text-gray-700 mb-3 block'>服务类型</Text>
|
||
<View className='flex flex-wrap gap-2'>
|
||
{serviceTypes.map(service => (
|
||
<View
|
||
key={service.value}
|
||
className={`px-3 py-2 rounded-full border ${
|
||
serviceType === service.value
|
||
? 'bg-green-50 border-green-500'
|
||
: 'bg-gray-50 border-gray-200'
|
||
}`}
|
||
onClick={() => setServiceType(service.value)}
|
||
>
|
||
<Text className={`text-sm ${serviceType === service.value ? 'text-green-600' : 'text-gray-600'}`}>
|
||
{service.label}
|
||
{service.price > 0 ? ` ¥${service.price}` : ''}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
|
||
{/* 联系人信息 */}
|
||
<View className='bg-white mx-3 mt-3 p-3 rounded-xl'>
|
||
<Text className='text-sm font-medium text-gray-700 mb-3 block'>联系人信息</Text>
|
||
<View className='mb-3'>
|
||
<Text className='text-xs text-gray-500 mb-1 block'>姓名</Text>
|
||
<Input
|
||
value={contactName}
|
||
onInput={(e) => setContactName(e.detail.value)}
|
||
placeholder='请输入姓名'
|
||
className='bg-gray-50 rounded-lg p-3 text-sm'
|
||
/>
|
||
</View>
|
||
<View>
|
||
<Text className='text-xs text-gray-500 mb-1 block'>手机号</Text>
|
||
<Input
|
||
type='number'
|
||
value={contactPhone}
|
||
onInput={(e) => setContactPhone(e.detail.value)}
|
||
placeholder='请输入手机号'
|
||
className='bg-gray-50 rounded-lg p-3 text-sm'
|
||
maxlength={11}
|
||
/>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 备注 */}
|
||
<View className='bg-white mx-3 mt-3 p-3 rounded-xl mb-24'>
|
||
<Text className='text-sm font-medium text-gray-700 mb-3 block'>备注(选填)</Text>
|
||
<Input
|
||
value={remark}
|
||
onInput={(e) => setRemark(e.detail.value)}
|
||
placeholder='如有特殊需求请注明(球拍型号、穿线磅数等)'
|
||
className='bg-gray-50 rounded-lg p-3 text-sm'
|
||
style={{ minHeight: '80px' }}
|
||
/>
|
||
</View>
|
||
</ScrollView>
|
||
|
||
<BottomButton
|
||
text='确认预约'
|
||
onClick={handleSubmit}
|
||
loading={submitting}
|
||
disabled={submitting}
|
||
subtitle={serviceType && selectedPrice > 0 ? `费用:¥${selectedPrice}(到店支付)` : undefined}
|
||
/>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
export default BookingPage
|