src/pages/robotaxi/home/hooks/use-navigation.ts
@@ -3,6 +3,16 @@ import { jumpTo } from '../../../../utils/jump'; import { getGeofence } from '../../../../robotaxi/api/home'; const OPERATING_GEOFENCE_TYPE = 1; // 仅渲染运营区围栏类型 /** * 过滤可渲染的围栏数据,仅保留 type=1 的运营区围栏。 */ const filterRenderableGeofence = (geofenceList: any[]) => { if (!Array.isArray(geofenceList)) return []; return geofenceList.filter((item) => Number(item?.type) === OPERATING_GEOFENCE_TYPE); }; type Deps = { /** 是否未登录 */ isGuest: boolean; @@ -207,8 +217,9 @@ let nextPolygons = deps.polygons; try { const res: any = await getGeofence({ cityCode: deps.selectedCity.code }); if (res && Array.isArray(res)) { nextPolygons = formatGeofencePolygons(res); const geofenceList = filterRenderableGeofence(res); if (geofenceList.length > 0) { nextPolygons = formatGeofencePolygons(geofenceList); deps.setPolygons(nextPolygons); } else { nextPolygons = []; src/pages/robotaxi/home/index.tsx
@@ -62,6 +62,15 @@ import { checkOtp } from '../../../utils/tools'; type HomeTripStatus = TripStatus | 0; const OPERATING_GEOFENCE_TYPE = 1; // 仅渲染围栏类型 2为场站类型 /** * 过滤可渲染的围栏数据,仅保留 type=1 的运营区围栏。 */ const filterRenderableGeofence = (geofenceList: any[]) => { if (!Array.isArray(geofenceList)) return []; return geofenceList.filter((item) => Number(item?.type) === OPERATING_GEOFENCE_TYPE); }; export default function Home() { const renderTailPreserveText = (text: string, tailCount = 3) => { @@ -252,8 +261,9 @@ const allowMapMove = !isLocationAuthDenied; getGeofence({ cityCode: selectedCity.code }).then((res: any) => { if (res && Array.isArray(res) && res.length > 0) { const newPolygons = formatGeofencePolygons(res); const geofenceList = filterRenderableGeofence(res); if (geofenceList.length > 0) { const newPolygons = formatGeofencePolygons(geofenceList); setPolygons(newPolygons); } else { setPolygons([]); src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx
New file @@ -0,0 +1,194 @@ import { View, Map, Text, Image, ScrollView } from '@tarojs/components'; import CustomerServicePopup from '../../../components/customer-service'; import TipPopup from '../components/tip-popup'; import { formatTripDistance, formatTripDurationFromSeconds } from '../../../../utils/Robotaxi-public-method'; type Deps = { longitude: number | null; latitude: number | null; mapScale: number; markers: any[]; mapPolylines: any[]; polygons: any[]; handleRegionChange: (e: any) => void; handleMarkerTap: (e: any) => void; toBack: () => void; backIcon: string; openService: () => void; toCoupon: () => void; toOrder: () => void; personalCenter: string; coupon: string; suggest: string; nearbyLoading: boolean; isAndroid: boolean; waitingRadarSrc: string; showToolTip: boolean; selectedSite: any; selectionMode: 'point' | 'station'; selectedStation: any; stationSiteList: any[]; outOfServiceRange: boolean; tooltipBgSrc: string; pageType: string; bounce: boolean; pointer: string; toOrigin: () => void; originIcon: string; walkingDuration: number | null; walkingDistance: number | null; nearbySites: any[]; handleCollect: (e: any) => void; changeStationSite: (site: any) => void; activeStar: string; noStar: string; confirmSite: () => void; showCustomerService: boolean; handleCloseCustomerService: () => void; showTipPopup: boolean; handleCloseTipPopup: () => void; tipType: string; }; /** * 选点页视图组件:仅负责渲染地图与页面 UI,不包含业务状态编排。 */ export default function SelectMapSiteView(deps: Deps) { return ( <View className="home-container"> <Map id="map" longitude={deps.longitude} latitude={deps.latitude} scale={deps.mapScale} markers={deps.markers.map((m) => ({ ...m, title: '', name: '' }))} polyline={deps.mapPolylines} polygons={deps.polygons} showLocation={!!deps.longitude} onRegionChange={deps.handleRegionChange} onMarkerTap={deps.handleMarkerTap} style={{ width: '100%', height: '100%' }} > {/* 返回 */} <View onClick={deps.toBack} className="select-city"> <Image className="select-city-icon" src={deps.backIcon} /> </View> {/* 地图浮层入口 */} <View className="map-entry"> <Image onClick={deps.openService} className="map-entry-icon" src={deps.personalCenter} /> <View className="split-line"></View> <Image onClick={deps.toCoupon} className="map-entry-icon" src={deps.coupon} /> <View className="split-line"></View> <Image onClick={deps.toOrder} className="map-entry-icon" src={deps.suggest} /> </View> {deps.nearbyLoading && deps.isAndroid && ( <Image className="waiting-radar-img" src={deps.waitingRadarSrc} /> )} {/* 指针 */} <View className="pointer-box"> {deps.nearbyLoading && !deps.isAndroid && ( <Image className="waiting-radar" src={deps.waitingRadarSrc} /> )} {((deps.showToolTip && deps.selectedSite.name) || deps.outOfServiceRange) && ( <View className="toolTip"> {(deps.outOfServiceRange || deps.selectedSite.time) && ( <View className="toolTip-bg" style={{ backgroundImage: `url(${deps.tooltipBgSrc})` }}> <Text className="toolTip-bg-text"> {deps.outOfServiceRange ? '不在运营区' : `限停${deps.selectedSite.time}分钟`} </Text> </View> )} <View className="toolTip-txt"> <View className="toolTip-txt-content"> {deps.outOfServiceRange ? `请拖动至${deps.pageType == 'end' ? '下车' : '上车'}点` : deps.selectedSite.name} </View> </View> </View> )} <Image className={`pointer-icon ${deps.bounce ? 'bounce' : ''}`} src={deps.pointer} /> </View> <View className="container"> <View className="container-inner"> {/* 复位按钮 */} <View className="origin-box"> <Image onClick={deps.toOrigin} className="origin-icon" src={deps.originIcon} /> </View> <View catchMove onClick={(e) => e.stopPropagation()}> {/* 确认站点 */} <View className="content-box"> <View className="content-box-item"> <View className='content-box-item-title'> <View className="content-title">{deps.selectionMode === 'station' ? deps.selectedStation?.name : (deps.pageType === 'start' ? '确认上车站点' : '确认目的地')}</View> {deps.pageType === 'start' && deps.walkingDuration !== null && deps.walkingDistance !== null && ( <View className="limit-tip"> 从当前位置出发步行{formatTripDurationFromSeconds(deps.walkingDuration)}({formatTripDistance(deps.walkingDistance)})上车 </View> )} {(deps.nearbySites.length > 0 && deps.pageType === 'end') && ( <View className="limit-tip">点击其他选项或紫色原点,可更改{deps.pageType === 'start' ? '上车' : '下车'}点</View> )} </View> {deps.selectionMode === 'station' && ( <Image onClick={deps.handleCollect} className='collect-icon' src={deps.selectedStation?.isCollect ? deps.activeStar : deps.noStar} /> )} </View> <View className='site-list-wrap'> <ScrollView className="site-list" scrollY enhanced showScrollbar={true} > <View className='site-list-inner'> {deps.selectionMode === 'station' ? ( deps.stationSiteList.length > 0 ? deps.stationSiteList.map((item) => ( <View key={item.id || item.pointId || `${item.latitude}_${item.longitude}`} className={`site-item ${String(item.id || item.pointId || '') === String(deps.selectedSite?.id || deps.selectedSite?.pointId || '') ? 'site-active' : ''}`} onClick={() => deps.changeStationSite(item)} > <View>{item.name}</View> </View> )) : <View className="no-service">当前场站暂无可选站点</View> ) : deps.selectedSite.name ? ( <View className='site-item site-active'> <View>{deps.selectedSite.name}</View> {deps.selectionMode === 'point' && ( <Image onClick={deps.handleCollect} className='collect-icon' src={deps.selectedSite.isCollect === 1 ? deps.activeStar : deps.noStar} /> )} </View> ) : ( <View className="no-service">超出服务范围,请在运营区内选择</View> )} </View> </ScrollView> </View> <View onClick={deps.confirmSite} className={`site-confirm ${deps.selectedSite.name ? '' : 'no-confirm'}`}> {deps.pageType === 'start' ? '确认上车站点' : '确认目的地站点'} </View> </View> </View> </View> </View> </Map> {/* 客服弹窗 */} <CustomerServicePopup show={deps.showCustomerService} onClose={deps.handleCloseCustomerService} /> {/* 提示弹窗 */} <TipPopup show={deps.showTipPopup} onClose={deps.handleCloseTipPopup} tipType={deps.tipType} /> </View> ); } src/robotaxi/pages/select-map-site/hooks/use-select-map-site-state.ts
New file @@ -0,0 +1,113 @@ import Taro from "@tarojs/taro"; import { useMemo, useRef, useState } from 'react'; /** * 选点页状态容器:仅维护页面状态、refs 与轻量派生数据,不承载业务副作用。 */ export default function useSelectMapSiteState() { const [showCustomerService, setShowCustomerService] = useState(false); // 客服弹窗是否显示 const [longitude, setLongitude] = useState<number | null>(null); // 经度 const [latitude, setLatitude] = useState<number | null>(null); // 纬度 const [mapScale, setMapScale] = useState(14); // 地图缩放等级 const [showToolTip, setShowToolTip] = useState(true); // 提示是否显示 const [bounce, setBounce] = useState(false); // 是否弹跳 const [markers, setMarkers] = useState<any[]>([]); // 标记点列表 const [pageType, setPageType] = useState('end'); // 起点start 终点end const [selectedSite, setSelectedSite] = useState<any>({}); // 选择的站点 const [selectionMode, setSelectionMode] = useState<'point' | 'station'>('point'); // 当前选择模式 const [selectedStation, setSelectedStation] = useState<any>(null); // 当前选中的场站 const [stationSiteList, setStationSiteList] = useState<any[]>([]); // 当前场站下站点列表 const [showTipPopup, setShowTipPopup] = useState(false); // 提示弹窗是否显示 const [tipType, setTipType] = useState(''); // 提示类型 1:上下车点近 2:距上车点远 const [polygons, setPolygons] = useState<any[]>([]); // 地图围栏 const [fencePolylines, setFencePolylines] = useState<any[]>([]); // 围栏虚线边框 const [isOperatingArea, setIsOperatingArea] = useState(false); // 是否在运营区域内 const [nearbySites, setNearbySites] = useState<any[]>([]); // 附近站点列表 const [distanceConfig, setDistanceConfig] = useState<any>({}); // 距离限制配置 const [polyline, setPolyline] = useState<any[]>([]); // 步行路线 const [userLongitude, setUserLongitude] = useState<number | null>(null); // 用户经度 const [userLatitude, setUserLatitude] = useState<number | null>(null); // 用户纬度 const [outOfServiceRange, setOutOfServiceRange] = useState(false); // 是否超出运营范围 const [walkingDistance, setWalkingDistance] = useState<number | null>(null); // 步行距离 const [walkingDuration, setWalkingDuration] = useState<number | null>(null); // 步行时长 const tooltipBgUrl = require('../../../../robotaxi/assets/tooltip-bg.png'); // 提示背景图资源 const [tooltipBgSrc, setTooltipBgSrc] = useState(tooltipBgUrl); // 提示背景图路径 const [isAndroid, setIsAndroid] = useState(false); // 是否安卓机型 const waitingRadarSrc = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/waiting-radar.webp'; // 加载雷达图 const [nearbyLoading, setNearbyLoading] = useState(false); // 附近站点加载态 const pendingNearbyRequestCountRef = useRef(0); // 待完成附近站点请求数 const autoScaleLockedRef = useRef(false); // 自动缩放锁:仅初始化计算一次 const stationMetaMapRef = useRef<Record<string, any>>({}); // 场站元数据映射(来自围栏) const router = Taro.useRouter(); // 路由实例 const mapPolylines = useMemo(() => { return [...fencePolylines, ...polyline]; }, [fencePolylines, polyline]); // 地图折线集合(围栏边框 + 步行线) return { showCustomerService, setShowCustomerService, longitude, setLongitude, latitude, setLatitude, mapScale, setMapScale, showToolTip, setShowToolTip, bounce, setBounce, markers, setMarkers, pageType, setPageType, selectedSite, setSelectedSite, selectionMode, setSelectionMode, selectedStation, setSelectedStation, stationSiteList, setStationSiteList, showTipPopup, setShowTipPopup, tipType, setTipType, polygons, setPolygons, fencePolylines, setFencePolylines, isOperatingArea, setIsOperatingArea, nearbySites, setNearbySites, distanceConfig, setDistanceConfig, polyline, setPolyline, userLongitude, setUserLongitude, userLatitude, setUserLatitude, outOfServiceRange, setOutOfServiceRange, walkingDistance, setWalkingDistance, walkingDuration, setWalkingDuration, tooltipBgUrl, tooltipBgSrc, setTooltipBgSrc, isAndroid, setIsAndroid, waitingRadarSrc, nearbyLoading, setNearbyLoading, pendingNearbyRequestCountRef, autoScaleLockedRef, stationMetaMapRef, router, mapPolylines, }; } src/robotaxi/pages/select-map-site/hooks/use-select-map-site-utils.ts
New file @@ -0,0 +1,86 @@ const FENCE_TYPE_SITE = 2; // 场站围栏类型 const FENCE_FILL_COLOR_TYPE_SITE = 'rgba(255, 255, 255, 0.3)'; // 场站围栏填充色 const FENCE_STROKE_COLOR_TYPE_SITE = '#FFFFFF'; // 场站围栏边框色 /** * 按围栏类型格式化地图覆盖物:type=2 使用白色填充与白色虚线边框。 */ export const formatGeofenceByType = (geofenceList: any[]) => { if (!Array.isArray(geofenceList)) { return { polygons: [], fencePolylines: [] }; } const polygons = geofenceList.map((item) => { const type = Number(item?.type); const points = Array.isArray(item?.points) ? item.points : []; return { points: points.map((p: any) => ({ latitude: p.lat, longitude: p.lng })), strokeWidth: type === FENCE_TYPE_SITE ? 1 : 2, strokeColor: type === FENCE_TYPE_SITE ? FENCE_STROKE_COLOR_TYPE_SITE : '#A1B1FF', fillColor: type === FENCE_TYPE_SITE ? FENCE_FILL_COLOR_TYPE_SITE : '#6d7fd933', zIndex: 1 }; }); const fencePolylines = geofenceList.reduce((acc: any[], item: any, index: number) => { const type = Number(item?.type); if (type !== FENCE_TYPE_SITE || !Array.isArray(item?.points) || item.points.length < 2) { return acc; } const linePoints = item.points.map((p: any) => ({ latitude: p.lat, longitude: p.lng })); // 虚线边框通过 polyline dottedLine 实现,首尾闭合形成围栏边界。 acc.push({ id: 900000 + index, points: [...linePoints, linePoints[0]], color: FENCE_STROKE_COLOR_TYPE_SITE, width: 2, dottedLine: true, arrowLine: false }); return acc; }, []); return { polygons, fencePolylines }; }; /** * 减少安卓端绘制点位,降低地图折线渲染压力。 */ export const reduceRoutePoints = (points: any[], maxCount: number) => { if (!Array.isArray(points) || points.length <= maxCount) { return points; } const step = Math.ceil(points.length / maxCount); const reduced = []; for (let i = 0; i < points.length; i += step) { reduced.push(points[i]); } const last = points[points.length - 1]; if (reduced[reduced.length - 1] !== last) { reduced.push(last); } return reduced; }; /** * 从围栏数据提取场站元信息映射:stationId -> 场站名称/收藏状态。 */ export const buildStationMetaMap = (geofenceList: any[]) => { const stationMetaMap: Record<string, any> = {}; if (!Array.isArray(geofenceList)) return stationMetaMap; geofenceList.forEach((item) => { const stationId = item?.stationId; if (!stationId) return; stationMetaMap[String(stationId)] = { stationId: String(stationId), name: item?.name || '', isCollect: !!item?.stationIsCollect, // 围栏返回的场站收藏字段:stationIsCollect(boolean) }; }); return stationMetaMap; }; src/robotaxi/pages/select-map-site/hooks/use-select-map-site-walking-route.ts
New file @@ -0,0 +1,72 @@ import { useEffect } from 'react'; import { getWalkingRoute } from '../../../../utils/Robotaxi-public-method'; import { reduceRoutePoints } from './use-select-map-site-utils'; type Deps = { pageType: string; userLatitude: number | null; userLongitude: number | null; latitude: number | null; longitude: number | null; selectedSite: any; isAndroid: boolean; setPolyline: (value: any[]) => void; setWalkingDistance: (value: number | null) => void; setWalkingDuration: (value: number | null) => void; }; /** * 步行路线副作用:根据当前位置与上车点变化计算步行路线并渲染折线。 */ export default function useSelectMapSiteWalkingRoute(deps: Deps) { useEffect(() => { if (deps.pageType !== 'start') { deps.setPolyline([]); deps.setWalkingDistance(null); deps.setWalkingDuration(null); return; } const originLat = deps.userLatitude ?? deps.latitude; const originLng = deps.userLongitude ?? deps.longitude; if (!originLat || !originLng || !deps.selectedSite?.latitude || !deps.selectedSite?.longitude) { deps.setPolyline([]); deps.setWalkingDistance(null); deps.setWalkingDuration(null); return; } const origin = `${originLng},${originLat}`; const destination = `${deps.selectedSite.longitude},${deps.selectedSite.latitude}`; getWalkingRoute(origin, destination).then(({ points, distance, duration }) => { deps.setWalkingDistance(distance); deps.setWalkingDuration(duration); if (!Array.isArray(points) || points.length === 0) { deps.setPolyline([]); return; } const safePoints = points.filter((item: any) => Number.isFinite(item?.latitude) && Number.isFinite(item?.longitude)); const displayPoints = deps.isAndroid ? reduceRoutePoints(safePoints, 120) : safePoints; if (displayPoints.length === 0) { deps.setPolyline([]); return; } deps.setPolyline([{ points: displayPoints, color: '#6D7FD9', width: 6, dottedLine: true, arrowLine: false, }]); }).catch(() => { deps.setPolyline([]); }); }, [ deps.pageType, deps.userLatitude, deps.userLongitude, deps.latitude, deps.longitude, deps.selectedSite?.latitude, deps.selectedSite?.longitude, deps.isAndroid ]); } src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts
New file @@ -0,0 +1,559 @@ import Taro from "@tarojs/taro"; import { useEffect } from 'react'; import { findNearestMarker, getAutoScaleBySites, getDistance, fetchMapData, extractPointsFromPolygons, preloadImageToTemp, } from '../../../../utils/Robotaxi-public-method'; import { getGeofence } from '../../../api/home'; import { getOrderDistance } from '../../../api/select-map-site'; import { collectPoint } from '../../../api/site-search'; import { checkOtp } from '../../../../utils/tools'; import { jumpTo } from '../../../../utils/jump'; import useSelectMapSiteState from './use-select-map-site-state'; import useSelectMapSiteWalkingRoute from './use-select-map-site-walking-route'; import { buildStationMetaMap, formatGeofenceByType } from './use-select-map-site-utils'; const AUTO_SCALE_MAX = 17; // 自动缩放最大等级 const AUTO_SCALE_MIN = 11; // 自动缩放最小等级 /** * 选点页业务编排:聚合状态容器与副作用 hooks,并对外提供稳定页面接口。 */ export default function useSelectMapSite() { const state = useSelectMapSiteState(); // 选点页状态容器 /** * 重置场站选择模式,回到普通站点模式。 */ const resetStationSelection = () => { state.setSelectionMode('point'); state.setSelectedStation(null); state.setStationSiteList([]); }; /** * 根据当前选中站点判断是否进入场站模式,并设置默认站点与场站信息。 */ const applySelectionBySite = (site: any, candidateSites?: any[]) => { const stationId = site?.stationId ? String(site.stationId) : ''; if (!stationId) { resetStationSelection(); state.setSelectedSite(site || {}); return site || {}; } const stationMeta = state.stationMetaMapRef.current[stationId]; const sourceSites = Array.isArray(candidateSites) ? candidateSites : state.nearbySites; const stationSites = sourceSites.filter((item: any) => String(item?.stationId || '') === stationId); if (!stationMeta || stationSites.length === 0) { resetStationSelection(); state.setSelectedSite(site || {}); return site || {}; } state.setSelectionMode('station'); state.setSelectedStation(stationMeta); state.setStationSiteList(stationSites); const defaultStationSite = stationSites[0]; // 进入场站模式默认选中首个站点 state.setSelectedSite(defaultStationSite); return defaultStationSite; }; /** * 计算是否超出可选范围(运营区外或起终点电子围栏不一致)。 */ const getOutOfServiceRange = (inside: boolean, site: any) => { const cachedStartSite = Taro.getStorageSync('startSite'); const cachedEndSite = Taro.getStorageSync('endSite'); const isCrossArea = (state.pageType === 'start' ? (cachedEndSite?.electronicAreaId && site?.electronicAreaId && cachedEndSite.electronicAreaId !== site.electronicAreaId) : (cachedStartSite?.electronicAreaId && site?.electronicAreaId && cachedStartSite.electronicAreaId !== site.electronicAreaId)); return !inside || isCrossArea; }; /** * 更新地图业务数据:围栏内判断、站点列表、自动缩放。 */ const updateMapData = async (lat: number, lng: number) => { const selectedCity = Taro.getStorageSync('selectedCity'); state.pendingNearbyRequestCountRef.current += 1; state.setNearbyLoading(true); try { const { isInside, newMarkers, sites } = await fetchMapData(lat, lng, selectedCity?.code); state.setIsOperatingArea(isInside); state.setMarkers(newMarkers); state.setNearbySites(sites); if (state.selectionMode === 'station' && state.selectedStation?.stationId) { const nextStationSites = sites.filter((item: any) => String(item?.stationId || '') === String(state.selectedStation.stationId)); state.setStationSiteList(nextStationSites); } if (!state.autoScaleLockedRef.current) { const autoScale = getAutoScaleBySites(sites?.length > 0 ? sites : newMarkers); if (typeof autoScale === 'number') { state.setMapScale((prev) => { const nextScale = Math.max(AUTO_SCALE_MIN, Math.min(AUTO_SCALE_MAX, autoScale)); if (Math.abs(prev - nextScale) < 0.01) return prev; return nextScale; }); } state.autoScaleLockedRef.current = true; } return { isInside, newMarkers, sites }; } finally { state.pendingNearbyRequestCountRef.current = Math.max(0, state.pendingNearbyRequestCountRef.current - 1); if (state.pendingNearbyRequestCountRef.current === 0) { state.setNearbyLoading(false); } } }; /** * 获取用户定位并作为地图中心点。 */ const getLocationInfo = () => { Taro.getLocation({ type: 'gcj02' }).then((res) => { state.setLatitude(res.latitude); state.setLongitude(res.longitude); updateMapData(res.latitude, res.longitude).then(({ isInside }) => { state.setOutOfServiceRange(getOutOfServiceRange(isInside, state.selectedSite)); }); }); }; /** * 获取用户定位,仅用于步行路线起点计算。 */ const fetchUserLocation = () => { Taro.getLocation({ type: 'gcj02' }).then((res) => { state.setUserLatitude(res.latitude); state.setUserLongitude(res.longitude); }); }; /** * 返回当前位置。 */ const toOrigin = () => { const mapCtx = Taro.createMapContext('map'); mapCtx.moveToLocation(); }; /** * 地图区域变化时触发,结束后按阈值吸附到最近站点。 */ const handleRegionChange = (e: any) => { const type = e?.type || e?.detail?.type; const causedBy = e?.causedBy || e?.detail?.causedBy; if (type === 'begin') { state.setShowToolTip(false); return; } if (type !== 'end') return; state.setBounce(true); if (causedBy === 'gesture' || causedBy === 'drag') { const mapCtx = Taro.createMapContext('map'); mapCtx.getCenterLocation({ success: async (res) => { const { latitude: lat, longitude: lng } = res; const { newMarkers, sites, isInside } = await updateMapData(lat, lng); const { targetMarker, minDistance } = findNearestMarker(lat, lng, newMarkers); if (targetMarker && minDistance < state.distanceConfig.adsorptionDistance) { mapCtx.moveToLocation({ latitude: targetMarker.latitude, longitude: targetMarker.longitude, }); const activeSite = applySelectionBySite(targetMarker, sites); state.setOutOfServiceRange(getOutOfServiceRange(isInside, activeSite)); state.setLatitude(activeSite?.latitude || targetMarker.latitude); state.setLongitude(activeSite?.longitude || targetMarker.longitude); state.setShowToolTip(true); } else { resetStationSelection(); state.setSelectedSite({}); state.setOutOfServiceRange(getOutOfServiceRange(isInside, {})); state.setLatitude(lat); state.setLongitude(lng); state.setShowToolTip(true); } } }); } setTimeout(() => { state.setBounce(false); state.setShowToolTip(true); }, 600); }; /** * 地图 marker 点击:直接聚焦并更新当前选中站点。 */ const handleMarkerTap = (e: any) => { const markerId = e?.detail?.markerId; if (markerId === undefined) return; const targetMarker = state.markers.find((m) => m.id === markerId); if (!targetMarker) return; state.setShowToolTip(false); state.setBounce(true); state.setLatitude(targetMarker.latitude); state.setLongitude(targetMarker.longitude); setTimeout(() => { state.setBounce(false); const activeSite = applySelectionBySite(targetMarker, state.nearbySites); state.setOutOfServiceRange(getOutOfServiceRange(state.isOperatingArea, activeSite)); state.setLatitude(activeSite?.latitude || targetMarker.latitude); state.setLongitude(activeSite?.longitude || targetMarker.longitude); state.setShowToolTip(true); }, 600); }; /** * 场站模式切换子站点:仅更新选中站点,不切换场站。 */ const changeStationSite = (site: any) => { if (!site?.latitude || !site?.longitude) return; state.setSelectedSite(site); state.setLatitude(site.latitude); state.setLongitude(site.longitude); const mapCtx = Taro.createMapContext('map'); mapCtx.moveToLocation({ latitude: site.latitude, longitude: site.longitude, }); }; /** * 关闭客服弹窗。 */ const handleCloseCustomerService = () => { state.setShowCustomerService(false); }; /** * 打开客服电话拨打。 */ const openService = () => { const phoneNumber = Taro.getStorageSync('customerServicePhone'); Taro.makePhoneCall({ phoneNumber }); }; /** * 返回上一页。 */ const toBack = () => { Taro.navigateBack({ delta: 1 }); }; /** * 确认选择站点(包含起终点距离与电子围栏校验)。 */ const confirmSite = async () => { if (!state.selectedSite.name) { Taro.showToast({ title: '请选择站点', icon: 'none' }); return; } if (state.pageType === 'start') { const endSiteCache = Taro.getStorageSync('endSite'); if (endSiteCache?.electronicAreaId && state.selectedSite?.electronicAreaId && endSiteCache.electronicAreaId !== state.selectedSite.electronicAreaId) { Taro.showToast({ title: '起终点需在同一运营区域', icon: 'none' }); return; } if (state.userLatitude && state.userLongitude) { const distance = getDistance(state.userLatitude, state.userLongitude, state.selectedSite.latitude, state.selectedSite.longitude); if (distance > state.distanceConfig.maxDistance) { state.setTipType('2'); state.setShowTipPopup(true); return; } } Taro.setStorageSync('startSite', state.selectedSite); if (state.router.params.skipSource) { Taro.navigateBack(); return; } Taro.navigateBack({ delta: 2 }); return; } const otp = await checkOtp(false); if (!otp) { jumpTo({ url: '/pages/user/login?hideBack=1' }); return; } const startSiteCache = Taro.getStorageSync('startSite'); if (startSiteCache?.electronicAreaId && state.selectedSite?.electronicAreaId && startSiteCache.electronicAreaId !== state.selectedSite.electronicAreaId) { Taro.showToast({ title: '起终点需在同一运营区域', icon: 'none' }); return; } if (startSiteCache && startSiteCache.longitude && startSiteCache.latitude) { const distance = getDistance(startSiteCache.latitude, startSiteCache.longitude, state.selectedSite.latitude, state.selectedSite.longitude); if (distance < state.distanceConfig.minDistance) { state.setTipType('1'); state.setShowTipPopup(true); return; } } Taro.setStorageSync('endSite', state.selectedSite); Taro.reLaunch({ url: Taro.getStorageSync('orderId') ? '/robotaxi/pages/begin-route/index?skipPageFlag=1' : '/robotaxi/pages/begin-route/index', }); }; /** * 关闭提示弹窗。 */ const handleCloseTipPopup = () => { state.setShowTipPopup(false); setTimeout(() => { state.setTipType(''); }, 600); }; /** * 跳转优惠券列表,未登录时先引导登录。 */ const toCoupon = async () => { const otp = await checkOtp(false); if (!otp) { jumpTo({ url: '/pages/user/login?hideBack=1' }); return; } Taro.navigateTo({ url: '/robotaxi/pages/coupon/index' }); }; /** * 跳转订单页,未登录时先引导登录。 */ const toOrder = async () => { const otp = await checkOtp(false); if (!otp) { jumpTo({ url: '/pages/user/login?hideBack=1' }); return; } Taro.navigateTo({ url: '/robotaxi/pages/order/index' }); }; /** * 收藏/取消收藏当前选中站点。 */ const handleCollect = async (e: any) => { e.stopPropagation(); try { const isStationMode = state.selectionMode === 'station' && state.selectedStation?.stationId; const collectSource = isStationMode ? state.selectedStation : state.selectedSite; const type = isStationMode ? (collectSource?.isCollect ? 2 : 1) : (collectSource?.isCollect === 1 ? 2 : 1); const payload: any = isStationMode ? { itemType: 2, stationId: String(state.selectedStation.stationId), type } // 场站收藏只传 stationId : { itemType: 1, latitude: state.selectedSite.latitude, longitude: state.selectedSite.longitude, type }; if (!isStationMode) { if (state.selectedSite.pointId) { payload.pointId = state.selectedSite.pointId; } else if (state.selectedSite.id) { payload.pointId = state.selectedSite.id; } } await collectPoint(payload); if (isStationMode) { const nextStation = { ...state.selectedStation, isCollect: type === 1 }; state.setSelectedStation(nextStation); state.stationMetaMapRef.current[String(nextStation.stationId)] = nextStation; } else { const nextSite = { ...state.selectedSite, isCollect: type }; state.setSelectedSite(nextSite); if (state.pageType === 'start') { Taro.setStorageSync('startSite', nextSite); } if (state.pageType === 'end') { Taro.setStorageSync('endSite', nextSite); } } Taro.showToast({ title: type === 2 ? '已取消收藏' : '收藏成功', icon: 'none' }); } catch (error) { console.log('收藏操作失败', error); } }; useEffect(() => { state.setOutOfServiceRange(getOutOfServiceRange(state.isOperatingArea, state.selectedSite)); }, [state.isOperatingArea, state.pageType, state.selectedSite?.electronicAreaId]); useEffect(() => { const systemInfo = Taro.getSystemInfoSync(); state.setIsAndroid(systemInfo?.platform === 'android'); const pType = state.router.params.pageType || 'end'; state.setPageType(pType); let cachedSite: any = null; if (pType === 'start') { cachedSite = Taro.getStorageSync('startSite'); } else if (pType === 'end') { cachedSite = Taro.getStorageSync('endSite'); if (!cachedSite) { cachedSite = Taro.getStorageSync('startSite'); } } const hasCachedSite = !!(cachedSite && cachedSite.latitude && cachedSite.longitude); if (hasCachedSite) { state.setLatitude(cachedSite.latitude); state.setLongitude(cachedSite.longitude); applySelectionBySite(cachedSite); updateMapData(cachedSite.latitude, cachedSite.longitude).then(({ isInside }) => { state.setOutOfServiceRange(getOutOfServiceRange(isInside, cachedSite)); }); } fetchUserLocation(); getOrderDistance().then((res: any) => { if (res) { state.setDistanceConfig({ maxDistance: res.maxDistance, minDistance: res.minDistance, adsorptionDistance: res.adsorptionDistance }); } }).catch((err) => { console.error('获取距离配置失败', err); }); const selectedCity = Taro.getStorageSync('selectedCity'); if (selectedCity && selectedCity.code) { getGeofence({ cityCode: selectedCity.code }).then((res: any) => { if (res && Array.isArray(res)) { const { polygons: newPolygons, fencePolylines: nextFencePolylines } = formatGeofenceByType(res); state.stationMetaMapRef.current = buildStationMetaMap(res); state.setPolygons(newPolygons); state.setFencePolylines(nextFencePolylines); if (!hasCachedSite && res.length > 0 && res[0]?.points?.length > 0) { const firstPoint = res[0].points[0]; const lat = Number(firstPoint.lat); const lng = Number(firstPoint.lng); state.setLatitude(lat); state.setLongitude(lng); updateMapData(lat, lng).then(({ isInside }) => { state.setOutOfServiceRange(getOutOfServiceRange(isInside, {})); }); const mapCtx = Taro.createMapContext('map'); const points = extractPointsFromPolygons(newPolygons); mapCtx.includePoints({ points, padding: [100, 100, 100, 100] }); } else if (!hasCachedSite) { getLocationInfo(); } } else { state.stationMetaMapRef.current = {}; state.setPolygons([]); state.setFencePolylines([]); if (!hasCachedSite) { getLocationInfo(); } } }).catch((err) => { console.error('获取电子围栏失败', err); state.stationMetaMapRef.current = {}; state.setPolygons([]); state.setFencePolylines([]); if (!hasCachedSite) { getLocationInfo(); } }); } else if (!hasCachedSite) { getLocationInfo(); } }, []); useEffect(() => { let cancelled = false; const run = async () => { const local = await preloadImageToTemp(state.tooltipBgUrl); if (!cancelled) state.setTooltipBgSrc(local); }; run(); return () => { cancelled = true; }; }, []); useSelectMapSiteWalkingRoute({ pageType: state.pageType, userLatitude: state.userLatitude, userLongitude: state.userLongitude, latitude: state.latitude, longitude: state.longitude, selectedSite: state.selectedSite, isAndroid: state.isAndroid, setPolyline: state.setPolyline, setWalkingDistance: state.setWalkingDistance, setWalkingDuration: state.setWalkingDuration, }); useEffect(() => { if (state.selectionMode !== 'station') return; if (!state.selectedStation?.stationId) return; const stationId = String(state.selectedStation.stationId); const nextStationSites = state.nearbySites.filter((item: any) => String(item?.stationId || '') === stationId); state.setStationSiteList(nextStationSites); if (!state.selectedSite?.stationId || String(state.selectedSite.stationId) !== stationId) { if (nextStationSites.length > 0) { state.setSelectedSite(nextStationSites[0]); } } }, [state.nearbySites, state.selectionMode, state.selectedStation?.stationId]); useEffect(() => { const stationId = state.selectedSite?.stationId ? String(state.selectedSite.stationId) : ''; if (!stationId || state.selectionMode === 'station') return; if (!state.stationMetaMapRef.current[stationId]) return; if (!Array.isArray(state.nearbySites) || state.nearbySites.length === 0) return; applySelectionBySite(state.selectedSite, state.nearbySites); }, [state.nearbySites, state.selectedSite?.stationId, state.selectionMode]); return { longitude: state.longitude, latitude: state.latitude, mapScale: state.mapScale, markers: state.markers, mapPolylines: state.mapPolylines, polygons: state.polygons, handleRegionChange, handleMarkerTap, toBack, openService, toCoupon, toOrder, nearbyLoading: state.nearbyLoading, isAndroid: state.isAndroid, waitingRadarSrc: state.waitingRadarSrc, showToolTip: state.showToolTip, selectedSite: state.selectedSite, selectionMode: state.selectionMode, selectedStation: state.selectedStation, stationSiteList: state.stationSiteList, outOfServiceRange: state.outOfServiceRange, tooltipBgSrc: state.tooltipBgSrc, pageType: state.pageType, bounce: state.bounce, toOrigin, walkingDuration: state.walkingDuration, walkingDistance: state.walkingDistance, nearbySites: state.nearbySites, handleCollect, changeStationSite, confirmSite, showCustomerService: state.showCustomerService, handleCloseCustomerService, showTipPopup: state.showTipPopup, handleCloseTipPopup, tipType: state.tipType, }; } src/robotaxi/pages/select-map-site/index.less
@@ -194,6 +194,23 @@ padding-bottom: 82px; background: #FFFFFF; .content-box-item { display: flex; justify-content: space-between; .content-box-item-title { flex: 1; flex-shrink: 0; } .collect-icon { margin-top: 6px; flex-shrink: 0; width: 30px; height: 28.64px; } } .content-limit-bg { position: absolute; top: 0; @@ -233,9 +250,18 @@ } } .site-list-wrap { position: relative; } .site-list { // height: 240px; overflow-y: auto; max-height: 360px; padding-right: 16px; box-sizing: border-box; } .site-list-inner { min-height: 100%; } .site-item { @@ -310,4 +336,4 @@ background: rgba(0, 0, 0, 0.15); } } } } src/robotaxi/pages/select-map-site/index.tsx
@@ -1,676 +1,68 @@ import Taro from "@tarojs/taro" import { useEffect, useRef, useState } from 'react'; import { View, Map, Text, Image, ScrollView } from '@tarojs/components'; import './index.less'; import SelectMapSiteView from './components/select-map-site-view'; import useSelectMapSite from './hooks/use-select-map-site'; import CustomerServicePopup from '../../components/customer-service'; import TipPopup from './components/tip-popup'; import backIcon from '../../../robotaxi/assets/back-icon.png'; import personalCenter from '../../../robotaxi/assets/personal-center.png'; import coupon from '../../../robotaxi/assets/coupon.png'; import suggest from '../../../robotaxi/assets/suggest.png'; import originIcon from '../../../robotaxi/assets/origin-icon.png'; import pointer from '../../../robotaxi/assets/pointer.png'; import activeStar from '../../../robotaxi/assets/active-star.png'; import noStar from '../../../robotaxi/assets/no-star.png'; import backIcon from '../../../robotaxi/assets/back-icon.png' import personalCenter from '../../../robotaxi/assets/personal-center.png' import coupon from '../../../robotaxi/assets/coupon.png' import suggest from '../../../robotaxi/assets/suggest.png' import originIcon from '../../../robotaxi/assets/origin-icon.png' import pointer from '../../../robotaxi/assets/pointer.png' import activeStar from '../../../robotaxi/assets/active-star.png' import noStar from '../../../robotaxi/assets/no-star.png' import { findNearestMarker, getAutoScaleBySites, getDistance, formatGeofencePolygons, fetchMapData, getWalkingRoute, extractPointsFromPolygons, preloadImageToTemp, formatTripDistance, formatTripDurationFromSeconds } from '../../../utils/Robotaxi-public-method'; import { getGeofence } from '../../api/home'; import { getOrderDistance } from '../../api/select-map-site'; import { collectPoint } from '../../api/site-search'; import { checkOtp } from '../../../utils/tools'; import { jumpTo } from '../../../utils/jump'; /** * 地图选点页入口:负责 hook 编排与视图组装。 */ export default function SelectMapSite() { const AUTO_SCALE_MAX = 17; // 自动缩放最大等级 const AUTO_SCALE_MIN = 11; // 自动缩放最小等级 const [showCustomerService, setShowCustomerService] = useState(false); // 客服弹窗是否显示 const [longitude, setLongitude] = useState<number | null>(null); // 经度 const [latitude, setLatitude] = useState<number | null>(null); // 纬度 const [mapScale, setMapScale] = useState(14); // 地图缩放等级 const [showToolTip, setShowToolTip] = useState(true); // 提示是否显示 const [bounce, setBounce] = useState(false); // 是否弹跳 const [markers, setMarkers] = useState<any[]>([]); // 标记点列表 const [pageType, setPageType] = useState('end'); // 起点start 终点end const [selectedSite, setSelectedSite] = useState<any>({}); // 选择的站点 const [showTipPopup, setShowTipPopup] = useState(false); // 提示弹窗是否显示 const [tipType, setTipType] = useState(''); // 提示类型 1:上下车点近 2:距上车点远 const [startSite, setStartSite] = useState<any>(null); // 上车点 const [polygons, setPolygons] = useState<any[]>([]); // 地图围栏 const [isOperatingArea, setIsOperatingArea] = useState(false); // 是否在运营区域内 const [nearbySites, setNearbySites] = useState<any[]>([]); // 附近站点列表 const [distanceConfig, setDistanceConfig] = useState({}); // 距离限制配置 const [polyline, setPolyline] = useState<any[]>([]); const [userLongitude, setUserLongitude] = useState<number | null>(null); const [userLatitude, setUserLatitude] = useState<number | null>(null); const [outOfServiceRange, setOutOfServiceRange] = useState(false); const [walkingDistance, setWalkingDistance] = useState<number | null>(null); const [walkingDuration, setWalkingDuration] = useState<number | null>(null); const tooltipBgUrl = require('../../../robotaxi/assets/tooltip-bg.png'); const [tooltipBgSrc, setTooltipBgSrc] = useState(tooltipBgUrl); const [isAndroid, setIsAndroid] = useState(false); const waitingRadarSrc = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/waiting-radar.webp'; const [nearbyLoading, setNearbyLoading] = useState(false); const pendingNearbyRequestCountRef = useRef(0); const autoScaleLockedRef = useRef(false); // 自动缩放锁:仅初始化计算一次 const router = Taro.useRouter(); const getOutOfServiceRange = (inside: boolean, site: any) => { const cachedStartSite = Taro.getStorageSync('startSite'); const cachedEndSite = Taro.getStorageSync('endSite'); const isCrossArea = (pageType === 'start' ? (cachedEndSite?.electronicAreaId && site?.electronicAreaId && cachedEndSite.electronicAreaId !== site.electronicAreaId) : (cachedStartSite?.electronicAreaId && site?.electronicAreaId && cachedStartSite.electronicAreaId !== site.electronicAreaId)); return !inside || isCrossArea; }; // 页面初始化 useEffect(() => { const systemInfo = Taro.getSystemInfoSync(); setIsAndroid(systemInfo?.platform === 'android'); const pType = router.params.pageType || 'end'; setPageType(pType); // 获取对应缓存 let cachedSite: any = null; if (pType === 'start') { const s = Taro.getStorageSync('startSite'); if (s) { setStartSite(s); cachedSite = s; } } else if (pType === 'end') { cachedSite = Taro.getStorageSync('endSite'); if (!cachedSite) { const s = Taro.getStorageSync('startSite'); if (s) { setStartSite(s); cachedSite = s; } } } const hasCachedSite = !!(cachedSite && cachedSite.latitude && cachedSite.longitude); // 有缓存,优先使用缓存回显 if (cachedSite && cachedSite.latitude && cachedSite.longitude) { setLatitude(cachedSite.latitude); setLongitude(cachedSite.longitude); setSelectedSite(cachedSite); updateMapData(cachedSite.latitude, cachedSite.longitude).then(({ isInside }) => { setOutOfServiceRange(getOutOfServiceRange(isInside, cachedSite)); }); } fetchUserLocation(); // 获取距离配置 getOrderDistance().then((res: any) => { if (res) { setDistanceConfig({ maxDistance: res.maxDistance, minDistance: res.minDistance, adsorptionDistance: res.adsorptionDistance }); } }).catch(err => { console.error('获取距离配置失败', err); }); // 获取电子围栏 const selectedCity = Taro.getStorageSync('selectedCity'); if (selectedCity && selectedCity.code) { getGeofence({ cityCode: selectedCity.code }).then((res: any) => { if (res && Array.isArray(res)) { const newPolygons = formatGeofencePolygons(res); setPolygons(newPolygons); if (!hasCachedSite && res.length > 0 && res[0]?.points?.length > 0) { const firstPoint = res[0].points[0]; const lat = Number(firstPoint.lat); const lng = Number(firstPoint.lng); setLatitude(lat); setLongitude(lng); updateMapData(lat, lng).then(({ isInside }) => { setOutOfServiceRange(getOutOfServiceRange(isInside, {})); }); const mapCtx = Taro.createMapContext('map'); // mapCtx.moveToLocation({ // latitude: lat, // longitude: lng, // }); const points = extractPointsFromPolygons(newPolygons); mapCtx.includePoints({ points: points, padding: [100, 100, 100, 100] }); } else if (!hasCachedSite) { getLocationInfo(); } } else { setPolygons([]); if (!hasCachedSite) { getLocationInfo(); } } }).catch(err => { console.error('获取电子围栏失败', err); setPolygons([]); if (!hasCachedSite) { getLocationInfo(); } }); } else if (!hasCachedSite) { getLocationInfo(); } }, []); useEffect(() => { let cancelled = false; const run = async () => { const local = await preloadImageToTemp(tooltipBgUrl); if (!cancelled) setTooltipBgSrc(local); }; run(); return () => { cancelled = true; }; }, []); // 更新地图数据 const updateMapData = async (lat: number, lng: number) => { const selectedCity = Taro.getStorageSync('selectedCity'); pendingNearbyRequestCountRef.current += 1; setNearbyLoading(true); try { const { isInside, electronicAreaId, newMarkers, sites } = await fetchMapData(lat, lng, selectedCity?.code); setIsOperatingArea(isInside); setMarkers(newMarkers); setNearbySites(sites); if (!autoScaleLockedRef.current) { const autoScale = getAutoScaleBySites(sites?.length > 0 ? sites : newMarkers); if (typeof autoScale === 'number') { setMapScale((prev) => { const nextScale = Math.max(AUTO_SCALE_MIN, Math.min(AUTO_SCALE_MAX, autoScale)); if (Math.abs(prev - nextScale) < 0.01) return prev; return nextScale; }); } autoScaleLockedRef.current = true; } return { isInside, newMarkers, sites }; } finally { pendingNearbyRequestCountRef.current = Math.max(0, pendingNearbyRequestCountRef.current - 1); if (pendingNearbyRequestCountRef.current === 0) { setNearbyLoading(false); } } } useEffect(() => { setOutOfServiceRange(getOutOfServiceRange(isOperatingArea, selectedSite)); }, [isOperatingArea, pageType, selectedSite?.electronicAreaId]); // 获取用户位置信息 const getLocationInfo = () => { Taro.getLocation({ type: 'gcj02' }) .then(res => { setLatitude(res.latitude); setLongitude(res.longitude); updateMapData(res.latitude, res.longitude).then(({ isInside }) => { setOutOfServiceRange(getOutOfServiceRange(isInside, selectedSite)); }); }) } const fetchUserLocation = () => { Taro.getLocation({ type: 'gcj02' }) .then(res => { setUserLatitude(res.latitude); setUserLongitude(res.longitude); }) } // 返回当前位置 const toOrigin = () => { const mapCtx = Taro.createMapContext('map'); mapCtx.moveToLocation(); } // 地图区域变化时触发,判断是否吸附到最近的站点停靠点 const handleRegionChange = (e: any) => { const type = e?.type || e?.detail?.type; // 获取触发源: gesture (手势), update (接口调用) const causedBy = e?.causedBy || e?.detail?.causedBy; if (type === 'begin') { setShowToolTip(false); } else if (type === 'end') { setBounce(true); // 只有用户手势拖动触发的移动才进行吸附判断,避免 moveToLocation 触发死循环 if (causedBy === 'gesture' || causedBy === 'drag') { const mapCtx = Taro.createMapContext('map'); mapCtx.getCenterLocation({ success: async (res) => { const { latitude: lat, longitude: lng } = res; const { newMarkers, isInside } = await updateMapData(lat, lng); const { targetMarker, minDistance } = findNearestMarker(lat, lng, newMarkers); // 如果最近的点在一定范围内(例如 200米),则吸附 if (targetMarker && minDistance < distanceConfig.adsorptionDistance) { // 使用 moveToLocation 实现吸附效果 mapCtx.moveToLocation({ latitude: targetMarker.latitude, longitude: targetMarker.longitude, }); setSelectedSite(targetMarker); setOutOfServiceRange(getOutOfServiceRange(isInside, targetMarker)); setLatitude(targetMarker.latitude); setLongitude(targetMarker.longitude); setShowToolTip(true); } else { setSelectedSite({}); setOutOfServiceRange(getOutOfServiceRange(isInside, {})); setLatitude(lat); setLongitude(lng); setShowToolTip(true); } } }); } setTimeout(() => { setBounce(false); setShowToolTip(true); }, 600); } } const handleCloseCustomerService = () => { setShowCustomerService(false); }; const openService = () => { const phoneNumber = Taro.getStorageSync('customerServicePhone'); Taro.makePhoneCall({ phoneNumber, }) } const toBack = () => { Taro.navigateBack({ delta: 1, }) } const confirmSite = async () => { if (!selectedSite.name) { Taro.showToast({ title: '请选择站点', icon: 'none', }) return } if (pageType === 'start') { const endSiteCache = Taro.getStorageSync('endSite'); if (endSiteCache?.electronicAreaId && selectedSite?.electronicAreaId && endSiteCache.electronicAreaId !== selectedSite.electronicAreaId) { Taro.showToast({ title: '起终点需在同一运营区域', icon: 'none', }) return } // 校验当前位置与选择的上车点距离 if (userLatitude && userLongitude) { const distance = getDistance(userLatitude, userLongitude, selectedSite.latitude, selectedSite.longitude); if (distance > distanceConfig.maxDistance) { setTipType('2'); setShowTipPopup(true); return; } } Taro.setStorageSync('startSite', selectedSite) if (router.params.skipSource) { Taro.navigateBack() return } Taro.navigateBack({ delta: 2, }) } if (pageType === 'end') { const otp = await checkOtp(false); if (!otp) { jumpTo({ url: '/pages/user/login?hideBack=1' }); return } const startSiteCache = Taro.getStorageSync('startSite'); if (startSiteCache?.electronicAreaId && selectedSite?.electronicAreaId && startSiteCache.electronicAreaId != selectedSite.electronicAreaId) { Taro.showToast({ title: '起终点需在同一运营区域', icon: 'none', }) return } // 校验上车点与目的地的距离 if (startSiteCache && startSiteCache.longitude && startSiteCache.latitude) { const distance = getDistance(startSiteCache.latitude, startSiteCache.longitude, selectedSite.latitude, selectedSite.longitude); if (distance < distanceConfig.minDistance) { setTipType('1'); setShowTipPopup(true); return; } } Taro.setStorageSync('endSite', selectedSite) Taro.reLaunch({ url: Taro.getStorageSync('orderId') ? `/robotaxi/pages/begin-route/index?skipPageFlag=1` : `/robotaxi/pages/begin-route/index`, }) } } // 关闭提示弹窗 const handleCloseTipPopup = () => { setShowTipPopup(false); setTimeout(() => { setTipType(''); }, 600); } // 切换子站点 const changeChildrenSite = (site: any) => { setSelectedSite(site); setLatitude(site.latitude); setLongitude(site.longitude); const mapCtx = Taro.createMapContext('map'); mapCtx.moveToLocation({ latitude: site.latitude, longitude: site.longitude, }); } const reducePoints = (points: any[], maxCount: number) => { if (!Array.isArray(points) || points.length <= maxCount) { return points; } const step = Math.ceil(points.length / maxCount); const reduced = []; for (let i = 0; i < points.length; i += step) { reduced.push(points[i]); } const last = points[points.length - 1]; if (reduced[reduced.length - 1] !== last) { reduced.push(last); } return reduced; } useEffect(() => { if (pageType !== 'start') { setPolyline([]); setWalkingDistance(null); setWalkingDuration(null); return; } const originLat = userLatitude ?? latitude; const originLng = userLongitude ?? longitude; if (!originLat || !originLng || !selectedSite?.latitude || !selectedSite?.longitude) { setPolyline([]); setWalkingDistance(null); setWalkingDuration(null); return; } const origin = `${originLng},${originLat}`; const destination = `${selectedSite.longitude},${selectedSite.latitude}`; getWalkingRoute(origin, destination).then(({ points, distance, duration }) => { setWalkingDistance(distance); setWalkingDuration(duration); if (!Array.isArray(points) || points.length === 0) { setPolyline([]); return; } const safePoints = points.filter((item: any) => Number.isFinite(item?.latitude) && Number.isFinite(item?.longitude)); const displayPoints = isAndroid ? reducePoints(safePoints, 120) : safePoints; if (displayPoints.length === 0) { setPolyline([]); return; } const line = [{ points: displayPoints, color: '#6D7FD9', width: 6, dottedLine: true, arrowLine: false, }]; setPolyline(line); }).catch(() => { setPolyline([]); }); }, [pageType, userLatitude, userLongitude, latitude, longitude, selectedSite?.latitude, selectedSite?.longitude, isAndroid]); const toCoupon = async () => { const otp = await checkOtp(false); if (!otp) { jumpTo({ url: '/pages/user/login?hideBack=1' }); return } Taro.navigateTo({ url: '/robotaxi/pages/coupon/index', }) } const toOrder = async () => { const otp = await checkOtp(false); if (!otp) { jumpTo({ url: '/pages/user/login?hideBack=1' }); return } Taro.navigateTo({ url: '/robotaxi/pages/order/index', }) } // 处理点击地图 marker const handleMarkerTap = (e: any) => { const markerId = e?.detail?.markerId; if (markerId === undefined) return; const targetMarker = markers.find(m => m.id === markerId); if (targetMarker) { // 先隐藏tooltip,并触发指针跳动动画 setShowToolTip(false); setBounce(true); // 直接更新经纬度实现跳转 setLatitude(targetMarker.latitude); setLongitude(targetMarker.longitude); // 等待指针动画(600ms)结束后,再展示tooltip和吸附状态 setTimeout(() => { setBounce(false); setSelectedSite(targetMarker); setOutOfServiceRange(getOutOfServiceRange(isOperatingArea, targetMarker)); setShowToolTip(true); }, 600); } }; const handleCollect = async (e: any) => { e.stopPropagation(); try { const type = selectedSite.isCollect === 1 ? 2 : 1; // 1=收藏 2=取消收藏 const payload: any = { itemType: 1, // 默认为站点处理 latitude: selectedSite.latitude, longitude: selectedSite.longitude, type: type }; // 处理可能不同的 id 字段名 (id / pointId / stationId) if (selectedSite.stationId) { payload.stationId = selectedSite.stationId; payload.itemType = 2; // 如果是场站 } else if (selectedSite.pointId) { payload.pointId = selectedSite.pointId; } else if (selectedSite.id) { payload.pointId = selectedSite.id; } await collectPoint(payload); setSelectedSite({ ...selectedSite, isCollect: type }); if (pageType === 'start') { Taro.setStorageSync('startSite', { ...selectedSite, isCollect: type }); } if (pageType === 'end') { Taro.setStorageSync('endSite', { ...selectedSite, isCollect: type }); } Taro.showToast({ title: type === 2 ? '已取消收藏' : '收藏成功', icon: 'none' }); } catch (error) { console.log('收藏操作失败', error); } }; const selectMapSite = useSelectMapSite(); return ( <View className="home-container"> <Map id="map" longitude={longitude} latitude={latitude} scale={mapScale} markers={markers.map(m => ({ ...m, title: '', name: '' }))} polyline={polyline} polygons={polygons} showLocation={!!longitude} onRegionChange={handleRegionChange} onMarkerTap={handleMarkerTap} style={{ width: '100%', height: '100%' }} > {/* 返回 */} <View onClick={toBack} className="select-city"> <Image className="select-city-icon" src={backIcon} /> </View> {/* 地图浮层入口 */} <View className="map-entry"> <Image onClick={openService} className="map-entry-icon" src={personalCenter} /> <View className="split-line"></View> <Image onClick={toCoupon} className="map-entry-icon" src={coupon} /> <View className="split-line"></View> <Image onClick={toOrder} className="map-entry-icon" src={suggest} /> </View> {nearbyLoading && isAndroid && ( <Image className="waiting-radar-img" src={waitingRadarSrc} /> )} {/* 指针 */} <View className="pointer-box"> {nearbyLoading && !isAndroid && ( <Image className="waiting-radar" src={waitingRadarSrc} /> )} {((showToolTip && selectedSite.name) || outOfServiceRange) && ( <View className="toolTip"> {(outOfServiceRange || selectedSite.time) && ( <View className="toolTip-bg" style={{ backgroundImage: `url(${tooltipBgSrc})` }}> <Text className="toolTip-bg-text">{outOfServiceRange ? `不在运营区` : `限停${selectedSite.time}分钟`}</Text> </View> )} <View className="toolTip-txt"> <View className="toolTip-txt-content">{outOfServiceRange ? `请拖动至${pageType == 'end' ? '下车' : '上车'}点` : selectedSite.name}</View> </View> </View> )} <Image className={`pointer-icon ${bounce ? 'bounce' : ''}`} src={pointer} /> </View> <View className="container"> <View className="container-inner"> {/* 复位按钮 */} <View className="origin-box"> <Image onClick={toOrigin} className="origin-icon" src={originIcon} /> </View> <View catchMove onClick={(e) => e.stopPropagation()}> {/* 确认站点 */}<View className="content-box"> {/* <View className="content-limit-bg"></View> <View className="limit-tip">目的地附近道路有<Text className="emphasis-txt">通行限制</Text>,为您推荐适合的下车位置</View> */} { true ? <View className="content-title">{pageType === 'start' ? '确认上车站点' : '确认目的地'}</View> : <View className="content-title"> <View>19号大院</View> <Image className="collect-icon" src={activeStar} /> </View> } { pageType === 'start' && walkingDuration !== null && walkingDistance !== null && <View className="limit-tip">从当前位置出发步行{formatTripDurationFromSeconds(walkingDuration)}({formatTripDistance(walkingDistance)})上车</View> } { (nearbySites.length > 0 && pageType === 'end') && <View className="limit-tip">点击其他选项或紫色原点,可更改{pageType === 'start' ? '上车' : '下车'}点</View> } <ScrollView className="site-list" scrollY> {/* { nearbySites.map(item => ( <View onClick={() => changeChildrenSite(item)} key={item.id} className={`site-name ${selectedSite.id == item.id && 'site-active'}`}>{item.name}</View> )) } */} { selectedSite.name ? true ? <View className='site-item site-active'> <View>{selectedSite.name}</View> {/* <Image className="collect-icon" src={activeStar} /> */} <Image onClick={handleCollect} className='collect-icon' src={selectedSite.isCollect === 1 ? activeStar : noStar} /> </View> : [1, 2].map((item) => ( <View key={item} className={`site-item ${item == 1 && 'site-active'}`}> <View>{selectedSite.name}</View> <Image className="collect-icon" src={activeStar} /> </View> )) : <View className="no-service">超出服务范围,请在运营区内选择</View> } </ScrollView> <View onClick={confirmSite} className={`site-confirm ${selectedSite.name ? '' : 'no-confirm'}`} >{pageType === 'start' ? '确认上车站点' : '确认目的地站点'}</View> </View> </View> </View> </View> </Map> {/* 客服弹窗 */} <CustomerServicePopup show={showCustomerService} onClose={handleCloseCustomerService} /> {/* 提示弹窗 */} <TipPopup show={showTipPopup} onClose={handleCloseTipPopup} tipType={tipType} /> </View> ) <SelectMapSiteView longitude={selectMapSite.longitude} latitude={selectMapSite.latitude} mapScale={selectMapSite.mapScale} markers={selectMapSite.markers} mapPolylines={selectMapSite.mapPolylines} polygons={selectMapSite.polygons} handleRegionChange={selectMapSite.handleRegionChange} handleMarkerTap={selectMapSite.handleMarkerTap} toBack={selectMapSite.toBack} backIcon={backIcon} openService={selectMapSite.openService} toCoupon={selectMapSite.toCoupon} toOrder={selectMapSite.toOrder} personalCenter={personalCenter} coupon={coupon} suggest={suggest} nearbyLoading={selectMapSite.nearbyLoading} isAndroid={selectMapSite.isAndroid} waitingRadarSrc={selectMapSite.waitingRadarSrc} showToolTip={selectMapSite.showToolTip} selectedSite={selectMapSite.selectedSite} selectionMode={selectMapSite.selectionMode} selectedStation={selectMapSite.selectedStation} stationSiteList={selectMapSite.stationSiteList} outOfServiceRange={selectMapSite.outOfServiceRange} tooltipBgSrc={selectMapSite.tooltipBgSrc} pageType={selectMapSite.pageType} bounce={selectMapSite.bounce} pointer={pointer} toOrigin={selectMapSite.toOrigin} originIcon={originIcon} walkingDuration={selectMapSite.walkingDuration} walkingDistance={selectMapSite.walkingDistance} nearbySites={selectMapSite.nearbySites} handleCollect={selectMapSite.handleCollect} changeStationSite={selectMapSite.changeStationSite} activeStar={activeStar} noStar={noStar} confirmSite={selectMapSite.confirmSite} showCustomerService={selectMapSite.showCustomerService} handleCloseCustomerService={selectMapSite.handleCloseCustomerService} showTipPopup={selectMapSite.showTipPopup} handleCloseTipPopup={selectMapSite.handleCloseTipPopup} tipType={selectMapSite.tipType} /> ); } src/robotaxi/pages/site-search/components/favorite-popup/index.tsx
@@ -69,6 +69,31 @@ } }; /** * 点击收藏项时根据类型分流: * itemType=2(场站) 取 children 第一个站点;children 为空则提示无有效站点。 */ const handleSelectItem = (item: any) => { const itemType = Number(item?.itemType); if (itemType === 2) { const children = Array.isArray(item?.children) ? item.children : []; if (children.length === 0) { Taro.showToast({ title: '此场站无有效站点', icon: 'none', }); return; } const firstSite = children[0] || {}; onSelect({ ...firstSite, stationId: item?.stationId || firstSite?.stationId, }); return; } onSelect(item); }; const onScroll = (e) => { const { scrollHeight, scrollTop } = e.detail; if (containerHeight > 0 && scrollHeight > containerHeight) { @@ -148,7 +173,7 @@ > <View className='favorite-list-inner'> {list.map((item, index) => ( <View key={item.id || index} className='favorite-item' onClick={() => onSelect(item)}> <View key={item.id || index} className='favorite-item' onClick={() => handleSelectItem(item)}> <View className='favorite-info'> <View className='favorite-name'>{item.pointName}</View> <View className='favorite-address'>{item.pointAddress}</View> src/robotaxi/pages/site-search/components/site-list-item.tsx
@@ -2,6 +2,7 @@ import { View, Image } from '@tarojs/components'; import { useState, useEffect } from 'react'; import { collectPoint, getCollectPoints } from '../../../api/site-search'; import tUpIcon from '../../../assets/T-up.png'; type Props = { // 站点数据 @@ -20,6 +21,8 @@ onSelect: (item: any) => void; // 高亮文本渲染器 renderHighlightedText: (text: string, className: string) => any; // 是否展示场站子站点信息 showStationChildren?: boolean; }; // 站点列表单项 @@ -31,14 +34,35 @@ noStarSrc, showDistance = true, onSelect, renderHighlightedText renderHighlightedText, showStationChildren = false }: Props) { const MAX_COLLECT_COUNT = 50; const MAX_VISIBLE_STATION_CHILDREN = 4; // 场站模式默认最多展示子站点数量 const [isCollected, setIsCollected] = useState(!!item.isCollected); const [expandStationChildren, setExpandStationChildren] = useState(false); // 是否展开全部场站子站点 useEffect(() => { setIsCollected(item.isCollect === 1); }, [item.isCollect]); useEffect(() => { setExpandStationChildren(false); }, [item?.id, item?.pointId, item?.stationId]); const isStationItem = Number(item?.itemType) === 2; // 当前条目是否场站 const stationChildren = Array.isArray(item?.children) ? item.children : []; // 场站子站点列表 const shouldShowStationChildren = showStationChildren && isStationItem && stationChildren.length > 0; // 是否渲染场站子站点盒子 const visibleStationChildren = expandStationChildren ? stationChildren : stationChildren.slice(0, MAX_VISIBLE_STATION_CHILDREN); /** * 获取子站点展示名称。 */ const getStationChildName = (child: any) => { return child?.name || child?.pointName || '--'; }; const checkCollectLimit = async () => { // 只取总数即可,避免为校验拉完整列表 @@ -116,12 +140,32 @@ </View> : null} </View> <View className='site-gate'> <View>东门</View> <View>北门</View> <View>西门</View> <View>南门</View> </View> {shouldShowStationChildren && ( <View className='site-gate'> {visibleStationChildren.map((child: any, index: number) => { const isMoreSlot = !expandStationChildren && stationChildren.length > MAX_VISIBLE_STATION_CHILDREN && index === MAX_VISIBLE_STATION_CHILDREN - 1; if (isMoreSlot) { return ( <View key='station-more' className='site-gate-item-more' onClick={() => setExpandStationChildren(true)} > <View>更多</View> <Image className='site-gate-more-icon' src={tUpIcon} /> </View> ); } return ( <View key={child?.id || child?.pointId || `${child?.latitude}_${child?.longitude}_${index}`}> {getStationChildName(child)} </View> ); })} </View> )} </View> ); } src/robotaxi/pages/site-search/index.less
@@ -197,6 +197,18 @@ letter-spacing: normal; color: rgba(0, 0, 0, 0.9); } .site-gate-item-more { display: flex; align-items: center; justify-content: center; column-gap: 8px; } .site-gate-more-icon { width: 16px; height: 16px; } } .show-more { @@ -336,4 +348,4 @@ width: 480px; height: 400px; } } } src/robotaxi/pages/site-search/index.tsx
@@ -232,7 +232,12 @@ ); } const renderSiteItems = (list: any[], onSelect: (item: any) => void, showDistance = true) => { // 复用列表项渲染 const renderSiteItems = ( list: any[], onSelect: (item: any) => void, showDistance = true, showStationChildren = false ) => { // 复用列表项渲染 return list.map((item, index) => ( <SiteListItem key={item.id || index} @@ -244,6 +249,7 @@ showDistance={showDistance} onSelect={onSelect} renderHighlightedText={renderHighlightedText} showStationChildren={showStationChildren} /> )); }; @@ -304,7 +310,7 @@ <View className='card-header'>历史记录</View> <View className='card-content list-content' id='history-card-content'> { renderSiteItems(visibleHistory, (item) => onChangeSelectSite(item), true) renderSiteItems(visibleHistory, (item) => onChangeSelectSite(item), true, true) } </View> {historySite.length > MAX_SHOW_COUNT && ( @@ -322,7 +328,7 @@ <View className='card-header'>{searchValue ? '推荐站点' : '附近站点'}</View> <View className='card-content list-content' id='recommend-card-content'> { renderSiteItems(visibleRecommend, (item) => onChangeSelectSite(item), true) renderSiteItems(visibleRecommend, (item) => onChangeSelectSite(item), true, true) } </View> {recommendList.length > MAX_SHOW_COUNT && (