feat(user): 新增收货地址管理及售后申请页面

- 新增地址类型定义,增强前端地址数据结构
- 新增地址编辑页面,支持地址智能识别和定位选点功能
- 地址编辑支持省市区选择及默认地址设置
- 新增地址列表页面,支持地址展示、删除、编辑和选择功能
- 实现售后申请页面,支持选择售后类型和退款原因
- 售后申请支持商品选择、退款金额计算和凭证上传
- 新增售后详情页面,支持售后状态展示及申请取消
- 优化页面加载和用户交互体验,增加错误提示和权限处理
This commit is contained in:
2026-07-01 12:11:56 +08:00
parent bf6ed504cc
commit 1fa58040f3
636 changed files with 58878 additions and 716 deletions

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '穿线预约',
}

View File

@@ -0,0 +1,69 @@
/* 穿线预约页面样式 */
/* 底部悬浮按钮 */
.bottom-button-container {
z-index: 999;
background-color: #ffffff;
padding: 12px 16px;
padding-bottom: 24px;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
}
.bottom-btn-subtitle {
text-align: center;
margin-bottom: 8px;
}
.subtitle-text {
font-size: 14px;
color: #666666;
}
.subtitle-price {
color: #ff6600;
font-weight: 500;
}
.bottom-btn {
width: 100%;
height: 88rpx;
background-color: #0e932e;
color: #ffffff;
font-size: 32rpx;
font-weight: 500;
border-radius: 44rpx;
border: none;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 88rpx;
}
.bottom-btn::after {
border: none;
}
.bottom-btn[disabled] {
background-color: #cccccc;
color: #ffffff;
}
.bottom-btn[loading] {
background-color: #0e932e;
opacity: 0.8;
}
/* 页面布局 */
.booking-page {
min-height: 100%;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
.booking-scroll {
flex: 1;
}

View File

@@ -0,0 +1,389 @@
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