台球开关台小程序
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

635 lines
18 KiB

/**
* 地图找店页面
*/
import { View, Text, Map, ScrollView, Image, Input } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { observer } from 'mobx-react'
import { useState, useRef, useEffect } from 'react'
import { Popup, Tabs, Toast } from '@taroify/core'
import { useCascader } from '@taroify/hooks'
import { Search, Arrow, Location } from '@taroify/icons'
import { chinaCityData } from '../../utils/cityData'
import './index.scss'
// 模拟门店数据(带经纬度)
const mockStoreData = [
{
id: '1',
name: '北京黑八大师联盟球厅店',
address: '北京市XXX区XXX街道XXX路19号606',
latitude: 39.910823,
longitude: 116.400470,
price: 16.00,
billiardCount: 6,
billiardEmpty: 6,
chessCount: 2,
chessEmpty: 2,
type: '直营店',
phone: '010-12345678',
distance: 0,
distanceText: '0.05km'
},
{
id: '2',
name: '北京晶彩桌球世界店',
address: '北京市XXX区XXX街道XXX路XXXXXX大厦101',
latitude: 39.905823,
longitude: 116.395470,
price: 24.00,
billiardCount: 8,
billiardEmpty: 8,
chessCount: 1,
chessEmpty: 1,
type: '加盟店',
phone: '010-87654321',
distance: 0,
distanceText: '0.1km'
},
{
id: '3',
name: '北京飓风台球店',
address: '北京市XXX区XXX街道106号A01',
latitude: 39.915823,
longitude: 116.405470,
price: 106.00,
billiardCount: 10,
billiardEmpty: 10,
chessCount: 0,
chessEmpty: 0,
type: '加盟店',
phone: '010-11223344',
distance: 0,
distanceText: '1.3km'
},
{
id: '4',
name: '北京星耀台球俱乐部',
address: '北京市XXX区XXX街道XXX路99号',
latitude: 39.900823,
longitude: 116.390470,
price: 30.00,
billiardCount: 12,
billiardEmpty: 10,
chessCount: 3,
chessEmpty: 2,
type: '直营店',
phone: '010-99887766',
distance: 0,
distanceText: '2.3km'
}
]
interface Store {
id: string
name: string
address: string
latitude: number
longitude: number
price: number
billiardCount: number
billiardEmpty: number
chessCount: number
chessEmpty: number
type: string
phone: string
distance: number
distanceText: string
}
interface Marker {
id: number
latitude: number
longitude: number
iconPath: string
width: number
height: number
callout?: any
label?: any
}
const MapStorePage = observer(() => {
// 用户位置(默认北京天安门)
const [userLocation, setUserLocation] = useState({
latitude: 39.908823,
longitude: 116.397470
})
// 地图中心点(默认跟随用户位置)
const [mapCenter, setMapCenter] = useState({
latitude: 39.908823,
longitude: 116.397470
})
const [storeList, setStoreList] = useState<Store[]>([])
const [markers, setMarkers] = useState<Marker[]>([])
const [activeStoreId, setActiveStoreId] = useState<string | null>(null)
const [searchValue, setSearchValue] = useState('')
const [selectedCity, setSelectedCity] = useState('选城市')
const [cityPopupOpen, setCityPopupOpen] = useState(false)
const [cityValue, setCityValue] = useState<string[]>([])
const [tabIndex, setTabIndex] = useState(0)
const mapContext = useRef<any>(null)
// 使用 useCascader hook
const { columns } = useCascader({
value: cityValue,
depth: 2,
options: chinaCityData,
})
useLoad(() => {
// 页面加载时获取用户位置
requestLocationPermission()
})
useEffect(() => {
// 初始化地图上下文
mapContext.current = Taro.createMapContext('storeMap')
}, [])
// 请求位置权限并获取位置
const requestLocationPermission = async () => {
try {
// 检查权限
const setting = await Taro.getSetting()
if (!setting.authSetting['scope.userLocation']) {
// 请求授权
await Taro.authorize({ scope: 'scope.userLocation' })
}
// 获取位置
Taro.showLoading({ title: '定位中...' })
const location = await Taro.getLocation({
type: 'gcj02', // 返回国测局坐标,适用于微信小程序地图
isHighAccuracy: true,
altitude: false
})
Taro.hideLoading()
const userPos = {
latitude: location.latitude,
longitude: location.longitude
}
setUserLocation(userPos)
setMapCenter(userPos)
// 获取附近门店(使用模拟数据)
fetchNearbyStores(userPos.latitude, userPos.longitude)
} catch (error) {
Taro.hideLoading()
console.error('获取位置失败:', error)
// 使用默认位置并加载门店
fetchNearbyStores(userLocation.latitude, userLocation.longitude)
Taro.showModal({
title: '定位提示',
content: '未能获取您的位置,将使用默认位置展示门店',
showCancel: false
})
}
}
// 获取附近门店(模拟接口)
const fetchNearbyStores = async (lat: number, lng: number) => {
try {
Taro.showLoading({ title: '加载中...' })
// TODO: 替换为真实接口
// const res = await Taro.request({
// url: 'https://your-api.com/api/stores/nearby',
// method: 'GET',
// data: {
// latitude: lat,
// longitude: lng,
// radius: 10000,
// limit: 50
// }
// })
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
// 使用模拟数据并计算距离
const storesWithDistance = mockStoreData.map(store => ({
...store,
distance: calculateDistance(lat, lng, store.latitude, store.longitude),
distanceText: formatDistance(
calculateDistance(lat, lng, store.latitude, store.longitude)
)
}))
// 按距离排序
storesWithDistance.sort((a, b) => a.distance - b.distance)
setStoreList(storesWithDistance)
// 生成地图标记
generateMarkers(storesWithDistance)
Taro.hideLoading()
} catch (error) {
Taro.hideLoading()
console.error('获取门店失败:', error)
Taro.showToast({
title: '获取门店失败',
icon: 'none'
})
}
}
// 计算两点之间的距离(米)- Haversine 公式
const calculateDistance = (
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number => {
const R = 6371e3 // 地球半径(米)
const φ1 = (lat1 * Math.PI) / 180
const φ2 = (lat2 * Math.PI) / 180
const Δφ = ((lat2 - lat1) * Math.PI) / 180
const Δλ = ((lng2 - lng1) * Math.PI) / 180
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c // 返回米
}
// 格式化距离显示
const formatDistance = (distance: number): string => {
if (distance < 1000) {
return `${distance.toFixed(0)}m`
} else {
return `${(distance / 1000).toFixed(2)}km`
}
}
// 生成地图标记
const generateMarkers = (stores: Store[]) => {
// 使用默认标记图标(微信小程序会提供默认图标)
// 如果需要自定义图标,准备好图片后替换 iconPath
const newMarkers: Marker[] = stores.map((store, index) => ({
id: index,
latitude: store.latitude,
longitude: store.longitude,
iconPath: '', // 空字符串使用默认图标,或指定自定义图标路径
width: 30,
height: 30,
callout: {
content: store.name,
color: '#333333',
fontSize: 12,
borderRadius: 8,
bgColor: '#FFFFFF',
padding: 8,
display: 'BYCLICK' // 点击标记时显示
}
}))
setMarkers(newMarkers)
}
// 点击地图标记
const handleMarkerTap = (e: any) => {
const markerIndex = e.detail.markerId
const store = storeList[markerIndex]
if (store) {
setActiveStoreId(store.id)
setMapCenter({
latitude: store.latitude,
longitude: store.longitude
})
}
}
// 点击列表项 - 打开导航
const handleStoreItemClick = (store: Store) => {
// 打开微信地图导航
Taro.openLocation({
latitude: store.latitude,
longitude: store.longitude,
name: store.name,
address: store.address,
scale: 18
})
}
// 重新定位
const handleRelocate = () => {
requestLocationPermission()
}
// 打开城市选择
const handleOpenCityPicker = () => {
setCityPopupOpen(true)
}
// 关闭城市选择
const handleCloseCityPicker = () => {
setCityPopupOpen(false)
setTabIndex(0)
}
// 选择城市选项
const handleSelectOption = (option: any, columnIndex: number) => {
const newValue = [...cityValue]
newValue[columnIndex] = option.value
// 清除后面的选中值
newValue.splice(columnIndex + 1)
setCityValue(newValue)
// 如果选择了最后一级(城市),关闭弹窗并调用接口
if (columnIndex === 1 || !option.children || option.children.length === 0) {
setSelectedCity(option.label)
setCityPopupOpen(false)
setTabIndex(0)
// 调用后端接口获取该城市的门店数据
fetchStoresByCity(option.label, option.value)
} else {
// 切换到下一个 tab
setTabIndex(columnIndex + 1)
}
}
// 根据城市获取门店数据
const fetchStoresByCity = async (cityLabel: string, cityValue: string) => {
try {
Toast.loading({
message: '加载中...',
duration: 0,
})
// TODO: 替换为真实接口
const res = await Taro.request({
url: 'https://your-api.com/api/stores/city',
method: 'GET',
data: {
city: cityValue,
latitude: userLocation.latitude,
longitude: userLocation.longitude,
}
})
// 暂时使用模拟数据
// if (res.statusCode === 200 && res.data.code === 200) {
// const stores = res.data.data.list
// setStoreList(stores)
// generateMarkers(stores)
// if (stores.length > 0) {
// setMapCenter({ latitude: stores[0].latitude, longitude: stores[0].longitude })
// }
// }
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
// 使用模拟数据
setStoreList(mockStoreData)
generateMarkers(mockStoreData)
Toast.close()
Toast.success(`已切换到${cityLabel}`)
} catch (error) {
Toast.close()
console.error('获取城市门店失败:', error)
Toast.open({
message: '获取门店数据失败',
icon: 'fail'
})
}
}
// 搜索门店
const handleSearch = async () => {
if (!searchValue.trim()) {
Toast.open('请输入门店名称或地址')
return
}
try {
Toast.loading({
message: '搜索中...',
duration: 0,
})
// TODO: 替换为真实搜索接口
const res = await Taro.request({
url: 'https://your-api.com/api/stores/search',
method: 'GET',
data: {
keyword: searchValue,
city: cityValue[1] || '',
latitude: userLocation.latitude,
longitude: userLocation.longitude,
}
})
// 暂时使用模拟数据
// if (res.statusCode === 200 && res.data.code === 200) {
// const stores = res.data.data.list
// setStoreList(stores)
// generateMarkers(stores)
// if (stores.length > 0) {
// setMapCenter({ latitude: stores[0].latitude, longitude: stores[0].longitude })
// }
// Toast.close()
// }
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
// 模拟搜索
const filtered = mockStoreData.filter(
store =>
store.name.includes(searchValue) || store.address.includes(searchValue)
)
Toast.close()
if (filtered.length > 0) {
setStoreList(filtered)
generateMarkers(filtered)
// 移动到第一个搜索结果
setMapCenter({
latitude: filtered[0].latitude,
longitude: filtered[0].longitude
})
Toast.success(`找到 ${filtered.length} 家门店`)
} else {
Toast.open('未找到相关门店')
}
} catch (error) {
Toast.close()
console.error('搜索门店失败:', error)
Toast.open({
message: '搜索失败',
icon: 'fail'
})
}
}
return (
<View className="map-store-page">
{/* 地图区域 */}
<Map
id="storeMap"
className="map-container"
longitude={mapCenter.longitude}
latitude={mapCenter.latitude}
scale={15}
markers={markers}
show-location
enable-zoom
enable-scroll
enable-rotate={false}
onMarkerTap={handleMarkerTap}
onError={() => {}}
/>
{/* 重新定位按钮 */}
<View className="relocate-btn" onClick={handleRelocate}>
<Location size={24} color="#333" />
</View>
{/* 底部内容区域 */}
<View className="content-area">
{/* 筛选区域 */}
<View className="filter-area">
<View className="filter-left">
{/* 城市选择 */}
<View className="city-select" onClick={handleOpenCityPicker}>
<Text className="city-text">{selectedCity}</Text>
<Arrow size={16} color="#999999" />
</View>
{/* 搜索输入框 */}
<View className="search-input-wrapper">
<Input
className="search-input"
placeholder="搜索门店名称/地址"
placeholderClass="search-placeholder"
value={searchValue}
onInput={(e) => setSearchValue(e.detail.value)}
/>
<View className="search-icon-btn" onClick={handleSearch}>
<Search size={18} color="#999999" />
</View>
</View>
</View>
</View>
{/* 门店列表 */}
<View className="list-header">
<Text className="list-title"></Text>
<Text className="list-count"> {storeList.length} </Text>
</View>
<ScrollView scrollY className="store-list">
{storeList.map((store) => (
<View
key={store.id}
className={`store-item ${activeStoreId === store.id ? 'active' : ''}`}
onClick={() => handleStoreItemClick(store)}
>
{/* 头部:名称和距离 */}
<View className="store-header">
<Text className="store-name">{store.name}</Text>
<Text className="store-distance">{store.distanceText}</Text>
</View>
{/* 地址 */}
<View className="store-address">
<Text className="address-text">{store.address}</Text>
</View>
{/* 价格信息 */}
<View className="store-price">
<View className="price-item">
<Text className="price-label"></Text>
<Text className="price-symbol">¥</Text>
<Text className="price-value">{store.price.toFixed(2)}</Text>
<Text className="price-unit"></Text>
</View>
<View className="price-item">
<Text className="price-label"></Text>
<Text className="price-symbol">¥</Text>
<Text className="price-value">{(store.price + 4).toFixed(2)}</Text>
<Text className="price-unit"></Text>
</View>
</View>
</View>
))}
{storeList.length === 0 && (
<View className="empty-state">
<Text className="empty-text"></Text>
</View>
)}
</ScrollView>
</View>
{/* 城市选择弹窗 - 使用 useCascader */}
<Popup
open={cityPopupOpen}
placement="bottom"
rounded
onClose={handleCloseCityPicker}
>
<View className="city-picker">
{/* 头部 */}
<View className="picker-header">
<Text className="picker-cancel" onClick={handleCloseCityPicker}></Text>
<Text className="picker-title"></Text>
<Text className="picker-confirm" onClick={handleCloseCityPicker}></Text>
</View>
{/* Tabs 切换 */}
<Tabs className="city-tabs" value={tabIndex} onChange={setTabIndex}>
{columns.map((column, columnIndex) => (
<Tabs.TabPane
key={columnIndex}
title={cityValue[columnIndex]
? column.find(opt => opt.value === cityValue[columnIndex])?.label || '请选择'
: '请选择'
}
>
<ScrollView scrollY className="city-scroll-view">
{column.map((option) => (
<View
key={option.value}
className={`city-item ${cityValue[columnIndex] === option.value ? 'active' : ''}`}
onClick={() => handleSelectOption(option, columnIndex)}
>
<Text className="city-item-text">{option.label}</Text>
</View>
))}
</ScrollView>
</Tabs.TabPane>
))}
</Tabs>
</View>
</Popup>
{/* Toast 组件 */}
<Toast id="toast" />
</View>
)
})
export default MapStorePage