fix(api): 替换所有接口请求的域名为 guilixu-api.websoft.top
- 将获取 STS Token 的接口地址由 paopao-api 改为 guilixu-api - 更新图片上传接口地址为新的 guilixu-api 域名 - 修改用户推广页面中邀请码链接和二维码接口的域名 - 更改注册页微信登录接口请求的域名为 guilixu-api
This commit is contained in:
3
src/pages/store/booking/index.config.ts
Normal file
3
src/pages/store/booking/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '穿线预约',
|
||||
}
|
||||
69
src/pages/store/booking/index.scss
Normal file
69
src/pages/store/booking/index.scss
Normal 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;
|
||||
}
|
||||
389
src/pages/store/booking/index.tsx
Normal file
389
src/pages/store/booking/index.tsx
Normal 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
|
||||
3
src/pages/store/list/index.config.ts
Normal file
3
src/pages/store/list/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '门店列表',
|
||||
}
|
||||
340
src/pages/store/list/index.tsx
Normal file
340
src/pages/store/list/index.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { View, Text, ScrollView, Input } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { listShopStore } from '@/api/shop/shopStore'
|
||||
import type { ShopStore } from '@/api/shop/shopStore/model'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '门店列表',
|
||||
})
|
||||
|
||||
/** 两点间距离(km),使用 Haversine 公式 */
|
||||
function calcDistance(lat1: number, lng1: number, lat2: number, lng2: number) {
|
||||
const R = 6371
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLng / 2) *
|
||||
Math.sin(dLng / 2)
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
}
|
||||
|
||||
/** 格式化距离显示 */
|
||||
function formatDistance(km: number) {
|
||||
return km < 1 ? `${Math.round(km * 1000)}m` : `${km.toFixed(1)}km`
|
||||
}
|
||||
|
||||
const StoreListPage: React.FC = () => {
|
||||
const params = Taro.getCurrentInstance().router?.params || {}
|
||||
// selectMode=1 时表示从预约页跳来,选中后回传
|
||||
const selectMode = params.selectMode === '1'
|
||||
|
||||
const [stores, setStores] = useState<ShopStore[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [locating, setLocating] = useState(false)
|
||||
const [hasLocation, setHasLocation] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const scrollHeight = useScrollHeight(44)
|
||||
const searchTimer = useRef<any>(null)
|
||||
const myLocation = useRef<{ lat: number; lng: number } | null>(null)
|
||||
const locationError = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
getLocation()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// 搜索防抖 500ms
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => {
|
||||
fetchStores()
|
||||
}, 500)
|
||||
return () => clearTimeout(searchTimer.current)
|
||||
}, [searchKeyword])
|
||||
|
||||
/** 获取当前定位,用于计算距离 */
|
||||
const getLocation = () => {
|
||||
setLocating(true)
|
||||
locationError.current = false
|
||||
Taro.getLocation({
|
||||
type: 'gcj02',
|
||||
success: (res) => {
|
||||
myLocation.current = { lat: res.latitude, lng: res.longitude }
|
||||
setHasLocation(true)
|
||||
fetchStores(res.latitude, res.longitude)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('定位失败:', err)
|
||||
locationError.current = true
|
||||
setHasLocation(false)
|
||||
// 用户拒绝授权时引导去设置页
|
||||
if (err.errMsg && err.errMsg.includes('auth deny')) {
|
||||
Taro.showModal({
|
||||
title: '定位授权',
|
||||
content: '需要获取您的位置信息才能按距离排序门店,是否去设置页开启定位权限?',
|
||||
confirmText: '去设置',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
Taro.openSetting()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
fetchStores()
|
||||
},
|
||||
complete: () => {
|
||||
setLocating(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const fetchStores = async (lat?: number, lng?: number) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const reqParams: any = { status: 1 }
|
||||
if (searchKeyword.trim()) {
|
||||
reqParams.keywords = searchKeyword.trim()
|
||||
}
|
||||
const data = await listShopStore(reqParams)
|
||||
let list = data || []
|
||||
|
||||
// 计算距离
|
||||
const userLat = lat ?? myLocation.current?.lat
|
||||
const userLng = lng ?? myLocation.current?.lng
|
||||
if (userLat && userLng) {
|
||||
list = list
|
||||
.map((store) => {
|
||||
if (store.lngAndLat) {
|
||||
const parts = store.lngAndLat.split(',')
|
||||
if (parts.length === 2) {
|
||||
const sLng = parseFloat(parts[0])
|
||||
const sLat = parseFloat(parts[1])
|
||||
if (sLat && sLng) {
|
||||
return { ...store, distance: calcDistance(userLat, userLng, sLat, sLng) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return store
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.distance == null && b.distance == null) return 0
|
||||
if (a.distance == null) return 1
|
||||
if (b.distance == null) return -1
|
||||
return a.distance - b.distance
|
||||
})
|
||||
}
|
||||
|
||||
setStores(list)
|
||||
} catch (e) {
|
||||
console.error('获取门店列表失败:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (e: any) => {
|
||||
setSearchKeyword(e.detail.value || '')
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchKeyword('')
|
||||
}
|
||||
|
||||
const handleCallStore = (phone: string) => {
|
||||
if (!phone) {
|
||||
Taro.showToast({ title: '暂无联系电话', icon: 'none' })
|
||||
return
|
||||
}
|
||||
Taro.makePhoneCall({
|
||||
phoneNumber: phone,
|
||||
fail: () => {
|
||||
Taro.showToast({ title: '拨打失败', icon: 'none' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenMap = (store: ShopStore) => {
|
||||
if (!store.lngAndLat) {
|
||||
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const parts = store.lngAndLat.split(',')
|
||||
if (parts.length !== 2) {
|
||||
Taro.showToast({ title: '位置信息格式有误', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const longitude = parseFloat(parts[0])
|
||||
const latitude = parseFloat(parts[1])
|
||||
if (!latitude || !longitude) {
|
||||
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
Taro.openLocation({
|
||||
latitude,
|
||||
longitude,
|
||||
name: store.name || '',
|
||||
address: store.address || '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleBooking = (store: ShopStore) => {
|
||||
if (selectMode) {
|
||||
// 选择模式:将门店信息回传给上一页
|
||||
const pages = Taro.getCurrentPages()
|
||||
const prevPage = pages[pages.length - 2]
|
||||
if (prevPage) {
|
||||
;(prevPage as any).setSelectedStore?.(store)
|
||||
}
|
||||
Taro.navigateBack()
|
||||
} else {
|
||||
Taro.navigateTo({ url: `/pages/store/booking/index?storeId=${store.id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50'>
|
||||
{/* 搜索框 + 定位按钮 */}
|
||||
<View className='bg-white px-3 py-3 sticky top-0 z-10'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<View className='flex-1 flex items-center bg-gray-100 rounded-lg px-3 py-2'>
|
||||
<Text className='text-gray-400 mr-2'>🔍</Text>
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onInput={handleSearch}
|
||||
placeholder='搜索门店名称'
|
||||
className='flex-1 text-sm'
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<View onClick={clearSearch}>
|
||||
<Text className='text-gray-400 text-sm'>✕</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
className='flex items-center justify-center bg-green-50 rounded-lg px-3 py-2'
|
||||
onClick={getLocation}
|
||||
>
|
||||
<Text className='text-xs text-green-600'>{locating ? '定位中' : '📡 定位'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{selectMode && (
|
||||
<Text className='text-xs text-gray-400 mt-2 block'>请选择预约门店</Text>
|
||||
)}
|
||||
|
||||
{/* 排序 / 距离提示条 */}
|
||||
{!loading && stores.length > 0 && (
|
||||
<View className='flex items-center justify-between mt-2 pt-2 border-t border-gray-50'>
|
||||
{hasLocation ? (
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-xs text-green-500'>📍</Text>
|
||||
<Text className='text-xs text-green-500'>已按距离由近到远排序</Text>
|
||||
</View>
|
||||
) : locating ? (
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-xs text-gray-400'>⏳</Text>
|
||||
<Text className='text-xs text-gray-400'>正在获取位置...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-xs text-gray-400'>📌</Text>
|
||||
<Text className='text-xs text-gray-400'>未获取位置</Text>
|
||||
</View>
|
||||
)}
|
||||
{!hasLocation && !locating && (
|
||||
<View
|
||||
className='px-2 py-1 bg-green-50 rounded'
|
||||
onClick={getLocation}
|
||||
>
|
||||
<Text className='text-xs text-green-600'>重新定位</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView scrollY style={{ height: scrollHeight }}>
|
||||
{loading ? (
|
||||
<View className='flex items-center justify-center py-10'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
) : stores.length === 0 ? (
|
||||
<EmptyState text='暂无门店' />
|
||||
) : (
|
||||
<View className='p-3'>
|
||||
{stores.map((store) => (
|
||||
<View key={store.id} className='bg-white rounded-xl p-4 mb-3 shadow-sm'>
|
||||
{/* 门店名 + 距离 */}
|
||||
<View className='flex justify-between items-start mb-2'>
|
||||
<Text className='text-base font-semibold text-gray-800 flex-1 mr-2'>
|
||||
{store.name}
|
||||
</Text>
|
||||
{store.distance != null && (
|
||||
<Text className='text-xs text-green-500 shrink-0'>
|
||||
{formatDistance(store.distance)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 地址 */}
|
||||
<View
|
||||
className='flex items-start mb-1'
|
||||
onClick={() => handleOpenMap(store)}
|
||||
>
|
||||
<Text className='text-xs text-blue-400 mr-1 shrink-0'>📍</Text>
|
||||
<Text className='text-xs text-blue-500 flex-1 underline'>
|
||||
{store.address || '暂无地址'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 营业时间 */}
|
||||
{store.businessHours ? (
|
||||
<View className='flex items-center mb-1'>
|
||||
<Text className='text-xs text-gray-400 mr-1 shrink-0'>🕐</Text>
|
||||
<Text className='text-xs text-gray-500'>{store.businessHours}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex items-center mb-1'>
|
||||
<Text className='text-xs text-gray-400 mr-1 shrink-0'>🕐</Text>
|
||||
<Text className='text-xs text-gray-400'>营业时间未设置</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-2 mt-3'>
|
||||
<View
|
||||
className='flex-1 py-2 bg-blue-50 rounded-lg text-center'
|
||||
onClick={() => handleCallStore(store.phone || '')}
|
||||
>
|
||||
<Text className='text-xs text-blue-500'>📞 拨打电话</Text>
|
||||
</View>
|
||||
<View
|
||||
className='flex-1 py-2 bg-gray-50 rounded-lg text-center'
|
||||
onClick={() => handleOpenMap(store)}
|
||||
>
|
||||
<Text className='text-xs text-gray-500'>🗺 查看地图</Text>
|
||||
</View>
|
||||
<View
|
||||
className='flex-1 py-2 rounded-lg text-center'
|
||||
style={{ background: selectMode ? '#0e932e' : '#fff7ed', border: selectMode ? 'none' : '1px solid #fed7aa' }}
|
||||
onClick={() => handleBooking(store)}
|
||||
>
|
||||
<Text className={`text-xs ${selectMode ? 'text-white' : 'text-orange-500'}`}>
|
||||
{selectMode ? '✓ 选择' : '📅 立即预约'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoreListPage
|
||||
5
src/pages/store/login/index.config.ts
Normal file
5
src/pages/store/login/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '门店登录',
|
||||
navigationBarBackgroundColor: '#667eea',
|
||||
navigationBarTextStyle: 'white'
|
||||
})
|
||||
137
src/pages/store/login/index.scss
Normal file
137
src/pages/store/login/index.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
// 门店登录页面
|
||||
.page-store-login {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease-in-out;
|
||||
|
||||
&--show {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.store-login-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.store-login-bg__gradient {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.store-login-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 120px 40px 60px;
|
||||
}
|
||||
|
||||
.store-login-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.store-login-logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 60px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.store-login-logo__image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.store-login-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.store-login-subtitle {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.store-login-form {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 24px;
|
||||
padding: 48px 36px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.store-login-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 24px 0;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&__icon {
|
||||
font-size: 36px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
font-size: 30px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.store-login-btn {
|
||||
margin-top: 48px;
|
||||
height: 96px;
|
||||
border-radius: 48px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4);
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.store-login-footer {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&__text {
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
132
src/pages/store/login/index.tsx
Normal file
132
src/pages/store/login/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text, Input, Image } from '@tarojs/components'
|
||||
import { storeLogin } from '@/api/shop/shopStore'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import logoImg from '@/assets/logo.png'
|
||||
import './index.scss'
|
||||
|
||||
const StoreLogin = () => {
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setShowContent(true), 100)
|
||||
}, [])
|
||||
|
||||
/** 解析 redirect 参数 */
|
||||
const router = Taro.getCurrentInstance().router
|
||||
const redirectUrl = (() => {
|
||||
const raw = (router?.params as Record<string, string> | undefined)?.redirect
|
||||
if (!raw) return ''
|
||||
try {
|
||||
const decoded = decodeURIComponent(raw)
|
||||
return decoded.startsWith('/') ? decoded : `/${decoded}`
|
||||
} catch {
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
})()
|
||||
|
||||
/** 登录成功后跳转 */
|
||||
const navigateAfterLogin = async () => {
|
||||
if (!redirectUrl) {
|
||||
await Taro.reLaunch({ url: '/pages/index/index' })
|
||||
return
|
||||
}
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
/** 账号密码登录 */
|
||||
const handleLogin = async () => {
|
||||
if (!phone.trim()) {
|
||||
Taro.showToast({ title: '请输入手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!password.trim()) {
|
||||
Taro.showToast({ title: '请输入密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await storeLogin({ phone: phone.trim(), password: password.trim() })
|
||||
if (res?.access_token) {
|
||||
const token = res.access_token
|
||||
const user = res.user
|
||||
saveStorageByLoginUser(token, user)
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => navigateAfterLogin(), 800)
|
||||
} else {
|
||||
Taro.showToast({ title: '登录失败', icon: 'none' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('门店登录失败:', e)
|
||||
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`page-store-login ${showContent ? 'page-store-login--show' : ''}`}>
|
||||
<View className='store-login-bg'>
|
||||
<View className='store-login-bg__gradient' />
|
||||
</View>
|
||||
|
||||
<View className='store-login-content'>
|
||||
<View className='store-login-header'>
|
||||
<View className='store-login-logo'>
|
||||
<Image className='store-login-logo__image' src={logoImg} mode='aspectFit' />
|
||||
</View>
|
||||
<Text className='store-login-title'>门店管理登录</Text>
|
||||
<Text className='store-login-subtitle'>请输入门店账号和密码</Text>
|
||||
</View>
|
||||
|
||||
<View className='store-login-form'>
|
||||
<View className='store-login-field'>
|
||||
<Text className='store-login-field__icon'>📱</Text>
|
||||
<Input
|
||||
className='store-login-field__input'
|
||||
type='text'
|
||||
placeholder='请输入手机号'
|
||||
maxlength={11}
|
||||
value={phone}
|
||||
onInput={(e) => setPhone(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='store-login-field'>
|
||||
<Text className='store-login-field__icon'>🔒</Text>
|
||||
<Input
|
||||
className='store-login-field__input'
|
||||
type='text'
|
||||
password
|
||||
placeholder='请输入密码'
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`store-login-btn ${loading ? 'store-login-btn--loading' : ''}`}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
<Text className='store-login-btn__text'>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='store-login-footer'>
|
||||
<Text className='store-login-footer__text'>
|
||||
门店账号由管理员创建,如需账号请联系管理员
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoreLogin
|
||||
Reference in New Issue
Block a user