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

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '门店列表',
}

View 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

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '门店登录',
navigationBarBackgroundColor: '#667eea',
navigationBarTextStyle: 'white'
})

View 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);
}
}

View File

@@ -0,0 +1,128 @@
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 './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'>
<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